<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Llmops on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/llmops/</link><description>Recent content in Llmops on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 15 Sep 2026 09:30:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/llmops/index.xml" rel="self" type="application/rss+xml"/><item><title>Anatomy of an LLM request in production, May 2026: a tour of the six stages following a single request</title><link>https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/</link><pubDate>Fri, 22 May 2026 16:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Across several series the blog has laid out the pieces that hold up an LLM system in production: the &lt;strong>Data stage&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">dataset versioning&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">ingestion and vector stores&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka&lt;/a>), the &lt;strong>Tune stage&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning&lt;/a>), the &lt;strong>Eval stage&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals as the layer after tracing&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails and safety&lt;/a>), the &lt;strong>Deploy stage&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">multi-tenant GPU cluster&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM operators on K8s&lt;/a>), the &lt;strong>Observe stage&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">tracing with AgentSight&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>), the &lt;strong>Retrain stage&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">closing the feedback → dataset → adapter loop&lt;/a>), and the cross-cutting components (&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">prompt versioning&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning&lt;/a>). What is missing is &lt;strong>joining them up&lt;/strong>: seeing a single request cross every piece in order, in one coherent story. That is what this post does. We take a specific request from a multi-tenant support chatbot, rewind it back to the data that trained the adapter serving it today, follow it forward through serving, watch it reach the feedback store when the user marks a thumbs-down, and leave it as the seed of the next quarterly retrain cycle. The route works as a mental map and as an integrator&amp;rsquo;s guide: the system does not hold up if a single one of the seven pieces (six stages + two cross-cutting) is broken or absent. The practical lesson of the tour is not a new one. It is that &lt;strong>everything is connected&lt;/strong>, that local measurements lie when taken in isolation, and that the real cost of not operating one stage well is paid by another stage further down the line.&lt;/p>
&lt;h2 id="you-are-here-every-stage-at-once">You are here: every stage at once&lt;/h2>
&lt;p>Unlike previous posts, where the mini-map marked a single active box, this one covers &lt;strong>the whole&lt;/strong> pipeline. It is the only post on the blog that activates the six stages and the two cross-cutting components simultaneously, because we follow a real request that crosses all of them.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 180" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="tour of every stage and cross-cutting component of the LLMOps pipeline">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:2.4}.cross{fill:#ffe9d6;stroke-width:1.6;stroke:#c66;rx:6}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#444}.tiny{font:600 10px sans-serif;fill:#333}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#trm)}.cyc{stroke:#c66;stroke-width:2;fill:none;stroke-dasharray:4 2;marker-end:url(#trm)}.trace{stroke:#1a73e8;stroke-width:2.4;fill:none}&lt;/style>
&lt;defs>&lt;marker id="trm" 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">Full tour: one request crosses the 6 stages and the 2 cross-cutting components&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box active"/>&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 active"/>&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 active"/>&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 active"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box active"/>&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;rect x="30" y="98" width="357" height="25" class="cross"/>
&lt;text x="208" y="115" text-anchor="middle" class="sm">Prompt versioning (Langfuse / MLflow Prompts)&lt;/text>
&lt;rect x="405" y="98" width="360" height="25" class="cross"/>
&lt;text x="585" y="115" text-anchor="middle" class="sm">Data versioning (DVC / lakeFS) · Schema Registry&lt;/text>
&lt;text x="50" y="148" class="tiny">trace_id · prompt_id · prompt_version · dataset_id · dataset_version · model_id · model_version · deployment_id&lt;/text>
&lt;path class="trace" d="M30,165 Q200,140 400,165 T760,160"/>
&lt;text x="755" y="172" text-anchor="end" class="tiny" fill="#1a73e8">trace running through the whole system&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-forensic-analysis-of-a-request">The analogy: forensic analysis of a request&lt;/h2>
&lt;p>When an air accident happens, the forensic analysis does not limit itself to the last seconds of the flight. The investigation team rewinds to the maintenance of the previous six months, the manufacturer&amp;rsquo;s protocols, the pilot&amp;rsquo;s record, the weather briefing, the controller&amp;rsquo;s decisions, the incident history on the same model. The conclusion is rarely &lt;em>&amp;ldquo;the wing broke&amp;rdquo;&lt;/em>; it is &lt;em>&amp;ldquo;the wing broke because an inspection protocol written a certain way did not detect microcracks that the 2014 calculation model did not consider critical and that were critical beyond a certain fatigue cycle&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>When an LLM request in production &lt;strong>fails&lt;/strong> or &lt;strong>succeeds&lt;/strong>, there is also a long causal chain behind it. The answer the user sees is the last frame; what determined it starts months earlier and branches across six operational stages. If you only look at the last frame, you attribute the result to the model. If you look at the whole chain, you see that the model is one of twelve factors and rarely the most important.&lt;/p>
&lt;p>This post does that forensic analysis, but the other way round: instead of starting from a failure and rewinding, we start from a &lt;strong>specific request that works&lt;/strong> and break down what had to happen for it to work, and what will happen to it afterwards. It is a guided tour, not an incident diagnosis. But the mental discipline is the same: no stage is autonomous, and understanding the system means understanding the bridges between stages, not just the boxes.&lt;/p>
&lt;h2 id="the-scenario-a-multi-tenant-support-chatbot-for-regulated-customers">The scenario: a multi-tenant support chatbot for regulated customers&lt;/h2>
&lt;p>For the tour we use a concrete realistic scenario, representative enough that the observations carry over to most serious deployments in May 2026. It is an &lt;strong>LLM customer support SaaS product&lt;/strong>, sold to several corporate customers (multi-tenant) in regulated sectors (banking, insurance, health). The product:&lt;/p>
&lt;ul>
&lt;li>Accepts natural-language questions through a chat embedded in the customer&amp;rsquo;s website.&lt;/li>
&lt;li>Retrieves relevant fragments from the customer&amp;rsquo;s internal knowledge base (product documents, contractual terms, FAQs).&lt;/li>
&lt;li>Answers citing the fragment used, in the user&amp;rsquo;s language, with a tone adapted to the brand.&lt;/li>
&lt;li>If the question requires an action (cancel, change plan, open a case), it invokes a &lt;strong>tool&lt;/strong> from the catalogue exposed via MCP, with user confirmation.&lt;/li>
&lt;li>It is contractually obliged not to invent customer data, to respect the tenant&amp;rsquo;s style guide, and to meet ENS / NIS2 compliance on data in the EU.&lt;/li>
&lt;/ul>
&lt;p>The infrastructure is &lt;strong>on-premise&lt;/strong> over Kubernetes (RKE2 with the Cilium CNI, in the pattern covered in &lt;a href="https://blog.lo0.es/en/posts/kubernetes-cilium-bgp-services-without-ingress/">Cilium BGP&lt;/a> and CNI networking). The GPU plane is a &lt;strong>generic 4×H100 SXM cluster&lt;/strong> (80 GB each, NVLink) for serving and production training, plus an &lt;strong>RTX 4090&lt;/strong> (24 GB) for development, offline evals and ad-hoc re-embeddings.&lt;/p>
&lt;p>The request we are going to follow:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>User&lt;/strong> (end customer, Spanish, premium plan with an insurer):
&lt;em>&amp;ldquo;How do I cancel my premium subscription? I have been paying for three months and I have not used half of the cover.&amp;rdquo;&lt;/em>&lt;/p>
&lt;/blockquote>
&lt;p>At first sight, a simple question. Let us see what is behind it.&lt;/p>
&lt;h2 id="step-0--the-frame-the-answer-the-user-sees">Step 0 — The frame: the answer the user sees&lt;/h2>
&lt;p>At 11:47:32 on a Tuesday, the user sends the message. 4.2 seconds later, this appears in the UI:&lt;/p>
&lt;blockquote>
&lt;p>To cancel your premium subscription, follow these steps: (1) Sign in to the customer portal with your ID number and password. (2) Go to &lt;em>My policies&lt;/em> → &lt;em>Premium Life 2024&lt;/em>. (3) Press &lt;em>Request cancellation&lt;/em>. (4) Select the reason (in your case, &amp;ldquo;not using the product&amp;rdquo;) and confirm. You will receive a confirmation email within 24-48 h.&lt;/p>
&lt;p>Before you cancel, let me remind you that your plan includes a &lt;strong>free annual medical check-up&lt;/strong> and &lt;strong>24/7 travel assistance&lt;/strong>, cover that is lost on cancellation. If you like, I can help you review your use of the cover over the past three months to see whether switching to a more tailored plan suits you better than cancelling.&lt;/p>
&lt;p>&lt;em>Source: [Cancellation policy, section 4.2 of the 2024 General Terms]&lt;/em>&lt;/p>
&lt;/blockquote>
&lt;p>After reading it, the user marks a &lt;strong>thumbs-down&lt;/strong> and writes in the form: &lt;em>&amp;ldquo;the answer is correct but the tone is too salesy; I only wanted to know how to cancel&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>That sequence, question, answer, thumbs-down with structured feedback, is the last visible frame. Let us go backwards to understand what had to happen for the answer to come out that way.&lt;/p>
&lt;h2 id="rewinding-backwards-what-was-already-in-place-before-the-request">Rewinding backwards: what was already in place before the request&lt;/h2>
&lt;p>Before the user typed, &lt;strong>the system already had&lt;/strong> a model loaded in serving, an active prompt labelled &lt;code>production&lt;/code>, an up-to-date vector index, a versioned dataset from the last fine-tuning, and a golden eval set that validated the promotion. Each of those artefacts got there through a process. We go through four jumps backwards.&lt;/p>
&lt;h3 id="t--90-days--the-previous-retrain-stage-closes-the-earlier-cycle">t = −90 days — The previous Retrain stage closes the earlier cycle&lt;/h3>
&lt;p>Three months ago, during a quarterly Retrain cycle, two things happened. The first: the support team reviewed the feedback accumulated over the previous six months and saw a pattern, the model answering premium users in an excessively formal tone, with users reporting that it &amp;ldquo;feels robotic&amp;rdquo;. The second: a one-off incident (a customer cancelling over an answer perceived as curt) triggered an incident-driven mini-cycle.&lt;/p>
&lt;p>The process, covered in detail in the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain post&lt;/a>, followed five sub-processes:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Feedback capture&lt;/strong> — explicit thumbs-down plus implicit feedback (abandonments, retries) accumulated in a &lt;code>feedback_signals&lt;/code> table in Postgres, all of them with a &lt;code>trace_id&lt;/code> that allows rewinding to the exact context.&lt;/li>
&lt;li>&lt;strong>Triage by root cause&lt;/strong> — the &amp;ldquo;curt tone&amp;rdquo; incident cluster was categorised as a &lt;code>prompt issue&lt;/code> (it was not the model answering badly, it was the system prompt asking for an overly formal register). One sub-cluster was a &lt;code>model issue&lt;/code> (in some cases the model dug its heels in even with a warmer prompt).&lt;/li>
&lt;li>&lt;strong>Dataset enrichment&lt;/strong> — the team manually annotated 280 cases where the model was too curt, labelled with the reference answer (&amp;ldquo;how it should have answered&amp;rdquo;). Double annotation on the critical 20%; cases with a quality score below 4 were left out.&lt;/li>
&lt;li>&lt;strong>Cadence decision&lt;/strong> — the incident was treated as incident-driven; the rest of the quarterly Retrain followed the calendar.&lt;/li>
&lt;li>&lt;strong>Promotion&lt;/strong> — the new &lt;code>customer_support_v7&lt;/code> adapter went through eval gates against &lt;code>customer_support_v6&lt;/code>, a 5% canary for a week, and was promoted when the golden set metrics showed a stable improvement in the &amp;ldquo;tone / clarity&amp;rdquo; segment with no regressions elsewhere.&lt;/li>
&lt;/ol>
&lt;p>The result: the adapter active in production when the user sent the Step 0 request is &lt;code>customer_support_v7&lt;/code>, trained on the enriched dataset &lt;code>enriched_retrain_2026_q1&lt;/code> version 3, with double lineage back to the original incident.&lt;/p>
&lt;h3 id="t--60-days--the-data-stage-the-enriched-dataset-is-versioned-and-enters-circulation">t = −60 days — The Data stage: the enriched dataset is versioned and enters circulation&lt;/h3>
&lt;p>Immediately after Retrain, the Data stage of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">LLMOps pipeline&lt;/a> does its work. Three critical operations, covered in detail in the &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning post&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Immutable versioning of the enriched dataset&lt;/strong> with DVC, sha256 hash propagated to the registry. The identifier &lt;code>(enriched_retrain_2026_q1, v3, sha256:9af...)&lt;/code> becomes the luggage tag that will travel through the coming stages.&lt;/li>
&lt;li>&lt;strong>Schema contract&lt;/strong> validated by CI: every row meets the JSON Schema of the entry the trainer expects (&lt;code>example_id&lt;/code>, &lt;code>input.user_query&lt;/code>, &lt;code>input.retrieved_context&lt;/code>, &lt;code>expected_output&lt;/code>, &lt;code>rubric&lt;/code>, &lt;code>segment&lt;/code>, &lt;code>difficulty&lt;/code>). A validation fails in CI if any row breaks the contract.&lt;/li>
&lt;li>&lt;strong>Holdout segregation check&lt;/strong>: the normalised sha256 hash of each &lt;code>input&lt;/code> is compared against every hash in the active golden eval set (&lt;code>customer_support_golden_v12&lt;/code>). Zero overlaps = the dataset does not contaminate the eval. Had there been a single one, CI would have blocked the merge.&lt;/li>
&lt;/ul>
&lt;p>In parallel, the &lt;strong>RAG corpus&lt;/strong> (product manuals, FAQs, general terms of the insurer tenant) is kept alive. The &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">ingestion pipeline&lt;/a> keeps capturing changes from the customer&amp;rsquo;s CMS: a new section of the cancellation policy was modified in February and reindexed in Qdrant. As the &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">post about RAG over Kafka&lt;/a> explains, the corpus is not retrained with every change: only the delta is re-embedded, and &lt;code>lakeFS&lt;/code> keeps a branch of the embeddings bucket with the new version. The branch is merged into &lt;code>main&lt;/code> when &lt;code>recall@10&lt;/code> over a representative set of queries stays above the threshold (0.78 in this system).&lt;/p>
&lt;h3 id="t--45-days--the-tune-stage-the-customer_support_v7-adapter-is-trained">t = −45 days — The Tune stage: the customer_support_v7 adapter is trained&lt;/h3>
&lt;p>Three weeks after closing the dataset, training of the new LoRA adapter starts. As the &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning post&lt;/a> details, the production pattern in 2026 avoids retraining the base model, which is expensive, slow and irreversible, and favours a &lt;strong>LoRA adapter over a stable base model&lt;/strong> (in this system, Llama 3 70B-instruct quantised to INT8 for serving). The training:&lt;/p>
&lt;ul>
&lt;li>Runs over 4 of the H100s (NVLink, tensor parallel) for ~6 hours.&lt;/li>
&lt;li>Uses &lt;code>transformers + PEFT + bitsandbytes&lt;/code>, monitored by MLflow.&lt;/li>
&lt;li>Every step records the &lt;code>dataset_id&lt;/code>, &lt;code>dataset_version&lt;/code> and &lt;code>dataset_hash&lt;/code> as an input artifact in MLflow.&lt;/li>
&lt;li>The output, a &lt;code>customer_support_v7.safetensors&lt;/code> file of ~280 MB with the LoRA weights, is uploaded to MinIO with its own hash, and MLflow records &lt;code>model_id, model_version, parent_dataset&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>At this point the lineage chain is closed for this leg:&lt;/p>
&lt;pre tabindex="0">&lt;code>enriched_retrain_2026_q1, v3, sha256:9af...
│
▼
mlflow run train, run_id: 0xa721...
│
▼
customer_support_v7, sha256:5c1...
&lt;/code>&lt;/pre>&lt;h3 id="t--38-days--the-eval-stage-the-v7-adapter-goes-through-eval-gates">t = −38 days — The Eval stage: the v7 adapter goes through eval gates&lt;/h3>
&lt;p>The freshly trained adapter is not promoted. It goes through an &lt;strong>eval suite&lt;/strong> covered in detail in the &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">post about evals&lt;/a>. The golden eval set, &lt;code>customer_support_golden_v12&lt;/code>, 850 human-curated examples with an inter-annotator kappa of 0.81, is run against two models: the candidate v7 adapter and the v6 currently in production. The metrics:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>v6 (prod)&lt;/th>
&lt;th>v7 (cand.)&lt;/th>
&lt;th>Threshold&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Faithfulness to the RAG fragment&lt;/td>
&lt;td>0.87&lt;/td>
&lt;td>0.89&lt;/td>
&lt;td>≥ 0.82&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Toxicity (low is good)&lt;/td>
&lt;td>0.012&lt;/td>
&lt;td>0.011&lt;/td>
&lt;td>≤ 0.02&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;Warm but professional&amp;rdquo; tone (judge LLM)&lt;/td>
&lt;td>0.71&lt;/td>
&lt;td>0.84&lt;/td>
&lt;td>≥ 0.78&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Format compliance (structured markdown)&lt;/td>
&lt;td>0.94&lt;/td>
&lt;td>0.93&lt;/td>
&lt;td>≥ 0.90&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Helpful-but-not-pushy (judge LLM)&lt;/td>
&lt;td>0.66&lt;/td>
&lt;td>0.79&lt;/td>
&lt;td>≥ 0.75&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Latency p95 (ms)&lt;/td>
&lt;td>2,840&lt;/td>
&lt;td>2,910&lt;/td>
&lt;td>≤ 3,500&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>To this is added the &lt;strong>guardrails and safety suite&lt;/strong> covered in the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails post&lt;/a>: jailbreak resistance, PII leakage detection, prompt injection over MCP tools. The v7 improves on safety in two metrics and ties on the rest.&lt;/p>
&lt;p>The v7 enters a 5% traffic canary for 7 days, under close monitoring. At the end of the canary, the &lt;strong>online metrics&lt;/strong> confirm what the offline ones anticipated: an improvement in tone and helpfulness, equivalent latency, no new failure modes. Promotion approved. The v7 moves to the &lt;code>production&lt;/code> label.&lt;/p>
&lt;h3 id="t--31-days--the-deploy-stage-the-v7-adapter-enters-serving">t = −31 days — The Deploy stage: the v7 adapter enters serving&lt;/h3>
&lt;p>The &lt;code>customer_support_v7&lt;/code> adapter is promoted to the serving cluster. Three pieces covered in separate posts come into play.&lt;/p>
&lt;p>&lt;strong>vLLM as the inference engine.&lt;/strong> The engine lives on Kubernetes, deployed via a dedicated Operator, as the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">post about LLM operators&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">post about vLLM on K8s&lt;/a> explain. The operator is responsible for detecting the new adapter in the registry, hot-loading it without restarting the engine (a native vLLM capability with &lt;code>--enable-lora&lt;/code>), and directing traffic based on the label.&lt;/p>
&lt;p>&lt;strong>Disaggregated serving.&lt;/strong> As the &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">post about disaggregated serving&lt;/a> details, the system separates &lt;strong>prefill&lt;/strong> (compute-intensive, throughput-bound) and &lt;strong>decode&lt;/strong> (memory-intensive, latency-bound) into different GPU pools. The user&amp;rsquo;s request, when it arrives, prefills in a specialised pod and decodes in another, communicating over NVLink plus a shared KV cache fabric.&lt;/p>
&lt;p>&lt;strong>Multi-tenant GPU cluster.&lt;/strong> The H100 cluster serves several tenants, not just the insurer from Step 0. As the &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">post about the multi-tenant cluster&lt;/a> explains, isolation is realised on four planes: the Kubernetes namespace, ACLs over adapters (only the tenant&amp;rsquo;s namespace loads its LoRAs), partitioning of the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> per tenant (one tenant cannot read another&amp;rsquo;s cached prefixes), and a tokens-per-minute quota enforced at the gateway.&lt;/p>
&lt;p>&lt;strong>Synchronised prompt registry.&lt;/strong> The product&amp;rsquo;s &lt;code>system_prompt&lt;/code> lives in Langfuse with the &lt;code>production&lt;/code> label. The active version is &lt;code>customer_support_system_prompt&lt;/code>, version 12. The gateway reads the prompt from Langfuse on the request path (with a few seconds of cache so it does not hammer the registry). Detailed in the &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">prompt versioning post&lt;/a>.&lt;/p>
&lt;p>The result at t = −31 days: the combination &lt;code>(adapter v7, prompt v12, golden v12)&lt;/code> is active and served. The system is ready for the request that will arrive 31 days later.&lt;/p>
&lt;h2 id="moving-forward-the-users-request-crosses-the-system">Moving forward: the user&amp;rsquo;s request crosses the system&lt;/h2>
&lt;p>Back to Step 0: 11:47:32 on a Tuesday. The user presses Enter. We go in real time, in milliseconds.&lt;/p>
&lt;h3 id="t--0-ms--entry-through-the-gateway">t = 0 ms — Entry through the gateway&lt;/h3>
&lt;p>The user&amp;rsquo;s browser POSTs to &lt;code>chat.aseguradora-ejemplo.com/api/chat&lt;/code>. The traffic crosses the edge load balancer and enters the SaaS product&amp;rsquo;s API gateway. The gateway:&lt;/p>
&lt;ul>
&lt;li>Authenticates the user&amp;rsquo;s JWT (an end customer of the insurer tenant).&lt;/li>
&lt;li>Extracts the &lt;code>tenant_id&lt;/code> and checks that its tokens-per-minute quota is not exhausted.&lt;/li>
&lt;li>Resolves which &lt;code>model_id&lt;/code>, &lt;code>adapter_id&lt;/code> and &lt;code>prompt_id&lt;/code> correspond to this tenant and product. In this case: &lt;code>llama-3-70b-int8&lt;/code> + &lt;code>customer_support_v7&lt;/code> + prompt label &lt;code>production&lt;/code>.&lt;/li>
&lt;li>Builds a unique &lt;code>trace_id&lt;/code> (W3C TraceContext, propagable to OTel) and starts a root span.&lt;/li>
&lt;/ul>
&lt;p>At 8 ms, the gateway passes the request to the prefill pool.&lt;/p>
&lt;h3 id="t--8-ms--pulling-the-versioned-prompt">t = 8 ms — Pulling the versioned prompt&lt;/h3>
&lt;p>Before serving, the OpenAI-compatible client the engine uses internally pulls the active system prompt. As the &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">post about prompt versioning&lt;/a> details, the pattern is:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">prompt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">prompt_registry&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pull&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;customer_support_system_prompt&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">label&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># now pointing at v12&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># A local 30 s cache reduces the round-trip to 0.1 % of requests&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The OTel span of the prompt pull carries the attributes &lt;code>gen_ai.prompt.id = customer_support_system_prompt&lt;/code>, &lt;code>gen_ai.prompt.version = 12&lt;/code>, &lt;code>gen_ai.prompt.label = production&lt;/code>. They are propagated to all children.&lt;/p>
&lt;h3 id="t--12-ms--rag-retrieval">t = 12 ms — RAG retrieval&lt;/h3>
&lt;p>The system needs context from the tenant&amp;rsquo;s knowledge base. It runs:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">query_embedding&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">encoder&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">user_query&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">chunks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">qdrant&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">collection&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;tenant_&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">_kb_v3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">vector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">query_embedding&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">score_threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.72&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">reranked&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">reranker&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">rerank&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">user_query&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">chunks&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">top_k&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At 38 ms, the reranker returns two fragments: one from the &lt;em>Cancellation policy, section 4.2&lt;/em> and another from &lt;em>Premium plan benefits, section 2.1&lt;/em>. As the &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">post about PostgreSQL + Qdrant&lt;/a> details, the tenant&amp;rsquo;s corpus is kept isolated by collection and ACL: no tenant can read another&amp;rsquo;s chunks.&lt;/p>
&lt;h3 id="t--40-ms--building-the-final-payload">t = 40 ms — Building the final payload&lt;/h3>
&lt;p>The engine composes:&lt;/p>
&lt;pre tabindex="0">&lt;code>[system_prompt v12]
+ [retrieved context: 2 chunks]
+ [short session history: 1 previous turn]
+ [user query]
&lt;/code>&lt;/pre>&lt;p>Total: ~1,850 tokens of context. The OTel span records &lt;code>gen_ai.request.input_tokens = 1850&lt;/code>, &lt;code>gen_ai.request.model = llama-3-70b-int8&lt;/code>, &lt;code>gen_ai.request.adapter = customer_support_v7&lt;/code>.&lt;/p>
&lt;h3 id="t--45-ms--prefill">t = 45 ms — Prefill&lt;/h3>
&lt;p>The payload enters the prefill pool. The GPU processes the 1,850 tokens in a single parallel pass, computing for each token its K and V vectors (attention key and value). Those vectors are materialised as the &lt;strong>KV cache&lt;/strong>, covered in detail in the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache fundamentals post&lt;/a>. The resulting cache takes up ~120 MB of VRAM in INT8.&lt;/p>
&lt;p>Here a key optimisation appears: the v12 system prompt is cached in the prefill pool (prefix caching, covered in the &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">post about PagedAttention&lt;/a>). Since the system prompt is the same for this tenant, the first ~500 tokens of the context are not recomputed: they are read from the prefix cache. That reduces the effective prefill from 1,850 tokens to ~1,350 tokens, saving ~270 ms of compute.&lt;/p>
&lt;p>At 580 ms (effective prefill), the TTFT (time to first token) is ready. The first token leaves for the decode pool.&lt;/p>
&lt;h3 id="t--580-ms--decode-streaming">t = 580 ms — Decode (streaming)&lt;/h3>
&lt;p>The decode pool receives the prefilled KV cache and starts generation token by token. As the &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">post about disaggregated serving&lt;/a> details, the prefill/decode separation is what lets a multi-tenant system keep TPS stable: the decode pool is sized to sustain thousands of sessions decoding in parallel at a low cost per token, while the prefill pool is sized for bursts of short TTFT.&lt;/p>
&lt;p>Generation at ~80 tokens per second. The answer will be ~290 tokens. Total decode time: ~3.6 s. Streaming: the user starts seeing words from t = 580 ms.&lt;/p>
&lt;p>While decode advances, the engine emits child spans on every iteration with &lt;code>gen_ai.response.tokens_generated&lt;/code>, &lt;code>gen_ai.response.cache_hit_ratio&lt;/code>, &lt;code>gen_ai.response.cumulative_latency&lt;/code>. The &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">post about AgentSight&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">post about MCP observability with OTel&lt;/a> cover the detailed instrumentation of this layer.&lt;/p>
&lt;h3 id="t--4200-ms--complete-answer-root-span-closed">t = 4,200 ms — Complete answer, root span closed&lt;/h3>
&lt;p>Generation finishes. The engine closes the root span with &lt;code>gen_ai.response.completion_tokens = 290&lt;/code>, &lt;code>gen_ai.response.finish_reason = stop&lt;/code>, &lt;code>gen_ai.response.total_latency_ms = 4200&lt;/code>. The user sees the final answer. The session is ready for a next turn or for the user to click thumbs-up/thumbs-down.&lt;/p>
&lt;p>By this point, every active stage has taken part:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Data&lt;/strong> (pre-existing): the indexed RAG corpus, the dataset that trained the adapter, the golden set that validated it.&lt;/li>
&lt;li>&lt;strong>Tune&lt;/strong> (pre-existing): the v7 adapter trained 45 days ago.&lt;/li>
&lt;li>&lt;strong>Eval&lt;/strong> (pre-existing): the gates that approved the promotion.&lt;/li>
&lt;li>&lt;strong>Deploy&lt;/strong> (at this very moment): vLLM + disaggregated + KV cache + multi-tenant.&lt;/li>
&lt;li>&lt;strong>Observe&lt;/strong> (at this very moment): the OTel spans emitted to Langfuse + Tempo, the metrics to Prometheus.&lt;/li>
&lt;li>&lt;strong>Retrain&lt;/strong> (about to activate): the feedback the user will leave in 15 seconds.&lt;/li>
&lt;/ul>
&lt;h2 id="in-parallel-observe-is-watching">In parallel: Observe is watching&lt;/h2>
&lt;p>While the request happens, several pieces of Observe run in parallel and leave a structured trail.&lt;/p>
&lt;p>&lt;strong>OTel tracing.&lt;/strong> Every span (gateway, prompt pull, retrieval, prefill, decode) travels to Langfuse and to an OTel collector that forwards them to a backend (Tempo / Jaeger). The unique &lt;code>trace_id&lt;/code> links all the spans. As the &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">post about tracing with AgentSight&lt;/a> details, end-to-end propagation is the main enabler of post-incident debugging: without it, you cannot reconstruct what happened three weeks later.&lt;/p>
&lt;p>&lt;strong>Runtime metrics.&lt;/strong> The engine emits Prometheus metrics per interval: &lt;code>gpu_utilization&lt;/code>, &lt;code>kv_cache_usage&lt;/code>, &lt;code>tokens_per_second&lt;/code>, &lt;code>queue_depth&lt;/code>, &lt;code>prefill_latency_p95&lt;/code>, &lt;code>decode_latency_p95&lt;/code>. The metrics are not tied to a trace; they are aggregated by tenant and service.&lt;/p>
&lt;p>&lt;strong>Online LLM-as-judge.&lt;/strong> A configurable percentage of answers (2% in this system) is also run through a judge LLM in the background, which scores the answer against a simple rubric (correct / partial / incorrect + a tone score). The judge does not block the answer to the user; it feeds the dashboard.&lt;/p>
&lt;p>&lt;strong>Statistical drift.&lt;/strong> In parallel, a slower pipeline computes drift over the distribution of inputs and outputs. As the &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">post about eBPF + drift&lt;/a> explains, low-level monitoring (latency, error rate per endpoint) is complemented with statistical drift detection (KS test, embedding distance) that spots when &amp;ldquo;something is wrong&amp;rdquo; before a thumbs-down confirms it.&lt;/p>
&lt;p>&lt;strong>Safety and guardrails monitor.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails post&lt;/a> describes the layer that watches for jailbreak attempts, PII leakage and prompt injection via MCP tools. In this case, none of them fires.&lt;/p>
&lt;p>All these pieces operate &lt;strong>continuously&lt;/strong>, not per request. But this particular request left its trail in every one of them.&lt;/p>
&lt;h2 id="the-feedback-the-loop-closes">The feedback: the loop closes&lt;/h2>
&lt;p>Fifteen seconds after reading the answer, the user marks a thumbs-down and writes in the form: &lt;em>&amp;ldquo;the answer is correct but the tone is too salesy; I only wanted to know how to cancel&amp;rdquo;&lt;/em>. That apparently trivial gesture triggers an important sequence.&lt;/p>
&lt;h3 id="insertion-into-feedback_signals">Insertion into feedback_signals&lt;/h3>
&lt;p>As the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">post about Retrain&lt;/a> details, the thumbs-down is persisted as a structured row in a Postgres table:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">feedback_signals&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">signal_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">trace_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">request_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">signal_type&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">signal_value&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">prompt_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">prompt_version&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">user_segment&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">occurred_at&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">gen_random_uuid&lt;/span>&lt;span class="p">(),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;4f5...&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- the trace_id from Step 0
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;r-22a...&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- request_id
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;thumbs&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;{&amp;#34;vote&amp;#34;:&amp;#34;down&amp;#34;,&amp;#34;reason&amp;#34;:&amp;#34;too pushy&amp;#34;,&amp;#34;text&amp;#34;:&amp;#34;I only wanted to know how to cancel&amp;#34;}&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;customer_support_system_prompt&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="mi">12&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;llama-3-70b-int8+customer_support_v7&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;premium-es&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;2026-05-19T11:47:51+02:00&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With this, the row is linked by &lt;code>trace_id&lt;/code> to everything that happened: prompt v12, retrieved context, complete output, latency metrics, judge score (0.82 in this case, considered good by the judge, though the human disagrees).&lt;/p>
&lt;h3 id="triage-by-root-cause">Triage by root cause&lt;/h3>
&lt;p>The MLE team runs triage the next morning. Combining heuristic rules, LLM-as-classifier and human review:&lt;/p>
&lt;ul>
&lt;li>The signal is not a &lt;code>model issue&lt;/code>: the model answered correctly to the prompt it received.&lt;/li>
&lt;li>It is not a &lt;code>retrieval issue&lt;/code>: the retrieved chunks were the right ones.&lt;/li>
&lt;li>It is not an &lt;code>infra issue&lt;/code>: latency was normal.&lt;/li>
&lt;li>It is a &lt;strong>&lt;code>prompt issue&lt;/code>&lt;/strong>: the v12 system prompt instructs the model to &amp;ldquo;offer alternatives before processing destructive actions&amp;rdquo;. That instruction generates the &amp;ldquo;salesy tone&amp;rdquo; in some contexts.&lt;/li>
&lt;/ul>
&lt;p>The incident accumulates with others from the month in the &amp;ldquo;salesy tone&amp;rdquo; cluster. When the cluster crosses a threshold (typically 30-50 incidents of the same type or a percentage of the total), it will enter an incident-driven mini-cycle or wait for the quarterly Retrain, depending on the size.&lt;/p>
&lt;h3 id="the-next-cycle-picks-it-up">The next cycle picks it up&lt;/h3>
&lt;p>Three months later, in the next quarterly Retrain, this feedback is one of many that will motivate two changes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>A new prompt version v13&lt;/strong> with an adjusted instruction: &amp;ldquo;offer alternatives only if the user does not express a clear intention to cancel&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>A possible reinforcement of the adapter&lt;/strong> with cases of a more direct tone for premium-es. If the cluster justifies it.&lt;/li>
&lt;/ul>
&lt;p>The v13 will go into its own eval gate. The golden set will grow with cases where the correct tone is &amp;ldquo;direct, not salesy&amp;rdquo;. The v8 of the adapter (if it arrives) will retrain over the enriched dataset &lt;code>enriched_retrain_2026_q2&lt;/code> that already contains this annotated case.&lt;/p>
&lt;p>The loop closes. The Step 0 request has contributed to the version of the system that will serve another user three months later.&lt;/p>
&lt;h2 id="what-goes-in-each-trace-identity-and-traceability">What goes in each trace: identity and traceability&lt;/h2>
&lt;p>If the reader looks at the seven identifiers that are everywhere in this route, they see the network of identities that makes all of the above possible. It is the &lt;strong>identity infrastructure&lt;/strong> of an LLM system in production:&lt;/p>
&lt;pre tabindex="0">&lt;code>trace_id 4f5... (unique per request)
request_id r-22a... (idem)
prompt_id customer_support_system_prompt
prompt_version 12
prompt_label production
dataset_id enriched_retrain_2026_q1
dataset_version v3 (sha256:9af...)
model_id llama-3-70b-int8
adapter_id customer_support_v7 (sha256:5c1...)
deployment_id d-prod-7b
schema_version 3.2
tenant_id aseguradora-ejemplo
user_segment premium-es
golden_set_id customer_support_golden_v12
&lt;/code>&lt;/pre>&lt;p>If a single piece of that set is missing or does not propagate, &lt;strong>the chain breaks&lt;/strong>. The next incident investigated will land in &amp;ldquo;we cannot rewind to the origin because the system did not record it&amp;rdquo;. That is why the cross-cutting components, prompt versioning and data versioning, are not luxuries: they are the connection without which the other six stages operate blind.&lt;/p>
&lt;h2 id="synthesis-diagram-how-the-pieces-fit">Synthesis diagram: how the pieces fit&lt;/h2>
&lt;pre tabindex="0">&lt;code> ┌─────────────────────────────────────────┐
│ User (end customer, B2C) │
└─────────────────┬───────────────────────┘
│ chat msg + JWT
▼
┌─────────────────────────────────────────┐
│ Edge LB + WAF + Cilium CNI │
└─────────────────┬───────────────────────┘
│ HTTPS, internal mTLS
▼
┌─────────────────────────────────────────────────┐
│ API Gateway (auth, quota, model routing) │
│ - Resolves tenant → model + adapter + prompt │
│ - Starts trace_id (W3C) │
└──────┬─────────────────────┬────────────────────┘
│ │
(pull prompt) │ │ (pull config)
▼ ▼
┌────────────────────┐ ┌──────────────────────┐
│ Langfuse Prompt │ │ Model registry │
│ Registry (v12) │ │ (adapter v7) │
└─────────┬──────────┘ └──────────┬───────────┘
│ │
└──────────┬───────────────┘
│ payload ready
▼
┌──────────────────────────────────────────┐
│ vLLM engine (K8s Operator) │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Pool prefill │ → │ Pool decode │ │
│ │ (H100×N) │ │ (H100×M) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ KV cache fabric │ │
│ └──────────────────┘ │
│ - prefix caching of the system prompt │
│ - PagedAttention │
└──────┬───────────────────────────────────┘
│ tokens stream
▼
┌─────────────────────────────────────────┐
│ User sees the answer + thumbs/UX UI │
└─────────────────┬───────────────────────┘
│ feedback (15 s later)
▼
┌─────────────────────────────────────────┐
│ feedback_signals (Postgres) │
│ + Langfuse scores │
└─────────────────┬───────────────────────┘
│
┌────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
triage quarterly Retrain cycle dataset_id
root cause or incident-driven enriched (DVC)
│
▼
Tune of v8
(next cycle)
In parallel throughout the request, OTel instrumentation:
spans → Tempo / Jaeger ; events → Langfuse ; metrics → Prometheus
&lt;/code>&lt;/pre>&lt;h2 id="the-on-premise-stack-applied">The on-premise stack applied&lt;/h2>
&lt;p>Taking the above to a generic on-premise consultant-profile infrastructure (RTX 4090 + 4×H100 SXM cluster):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Typical resources&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Network plane&lt;/td>
&lt;td>Edge LB (HAProxy / nginx ingress) + Cilium CNI with BGP, covered in &lt;a href="https://blog.lo0.es/en/posts/kubernetes-cilium-bgp-services-without-ingress/">Cilium BGP&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>K8s compute plane&lt;/td>
&lt;td>RKE2 with two manager nodes + a GPU node pool&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Production GPU plane&lt;/td>
&lt;td>4× H100 SXM (NVLink, 80 GB each), partitioned via MIG into prefill/decode pools&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Development GPU plane&lt;/td>
&lt;td>1× RTX 4090 (24 GB) for offline evals, drift-check embeddings, smoke tests&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Storage plane&lt;/td>
&lt;td>MinIO or Ceph object store; DVC remote + lakeFS backend&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OLTP data plane&lt;/td>
&lt;td>Postgres 18 with replication; pgvector 0.8 for small cases&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Vector plane&lt;/td>
&lt;td>Qdrant or Milvus for large RAG corpora&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Stream plane&lt;/td>
&lt;td>Kafka (Redpanda / pure Apache) + Schema Registry; CDC with Debezium or Flink CDC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Observability plane&lt;/td>
&lt;td>OTel Collector + Tempo (traces) + Prometheus (metrics) + Loki (logs); Langfuse for the LLM-specific part&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Runtime security plane&lt;/td>
&lt;td>Tetragon, covered in the &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">runtime security post&lt;/a>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The real density is not the sum of the boxes: it is the &lt;strong>operations&lt;/strong> that tie the boxes together. A cluster with every piece but no versioning discipline, no end-to-end &lt;code>trace_id&lt;/code> propagation, no schema contracts and no cadenced retraining is a cluster that serves LLM &lt;strong>once&lt;/strong> and then ages. The difference between a project and a platform is exactly that.&lt;/p>
&lt;h2 id="ten-bridges-between-stages-where-the-system-breaks">Ten bridges between stages where the system breaks&lt;/h2>
&lt;p>The route reveals something important: failures are rarely &lt;strong>inside&lt;/strong> a stage; they are on the &lt;strong>bridges&lt;/strong> between stages. Ten common bridges:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Data → Tune&lt;/strong>: the dataset does not propagate its &lt;code>(dataset_id, dataset_version)&lt;/code> to the trainer. The same dataset trained twice produces two &lt;code>model_id&lt;/code> values that cannot be told apart.&lt;/li>
&lt;li>&lt;strong>Tune → Eval&lt;/strong>: the trained model does not propagate its lineage to the eval run. The eval passes, but there is no record of which dataset it was trained on. Three months later, irreproducible.&lt;/li>
&lt;li>&lt;strong>Eval → Deploy&lt;/strong>: promotion happens without the serving system recording which version of the adapter it is serving at each moment. The day the model gives a dangerous answer, nobody knows which adapter answered.&lt;/li>
&lt;li>&lt;strong>Deploy → Observe&lt;/strong>: the engine does not emit &lt;code>gen_ai.request.adapter&lt;/code>, &lt;code>gen_ai.prompt.version&lt;/code>, &lt;code>gen_ai.dataset.version&lt;/code> as span attributes. The traces exist but cannot be crossed with the lineage.&lt;/li>
&lt;li>&lt;strong>Observe → Retrain&lt;/strong>: the feedback is captured in a tool (Langfuse, Phoenix) but nobody reads it. The Retrain stage &amp;ldquo;exists&amp;rdquo;, but feedback piles up untriaged.&lt;/li>
&lt;li>&lt;strong>Retrain → Data&lt;/strong>: the enriched dataset goes into the next Tune without passing through versioning discipline, schema contract and holdout check. Silent contamination of the golden set.&lt;/li>
&lt;li>&lt;strong>Prompt versioning ↔ everything&lt;/strong>: the &lt;code>prompt_id, prompt_version&lt;/code> does not propagate to the spans. The day the team discovers that a prompt change regressed the system, it cannot isolate which one or when.&lt;/li>
&lt;li>&lt;strong>Data versioning ↔ everything&lt;/strong>: the &lt;code>dataset_id, dataset_version&lt;/code> does not show up in experiment tracking. &amp;ldquo;v8 is retrained&amp;rdquo; but nobody can prove it was over the enriched dataset and not the old one.&lt;/li>
&lt;li>&lt;strong>MCP ↔ tools&lt;/strong>: the system invokes tools (cancellation, policy changes) but does not record a &lt;code>gen_ai.tool.invocation_id&lt;/code> linked to the trace. The actions end up dissociated from the answer that generated them.&lt;/li>
&lt;li>&lt;strong>Schema Registry ↔ data&lt;/strong>: datasets version content but not schema. A breaking change in &lt;code>expected_output&lt;/code> breaks the eval silently; nobody notices anything until a human reviews the results.&lt;/li>
&lt;/ol>
&lt;p>The bridges are covered throughout the blog. Operations enforce them. The team&amp;rsquo;s culture keeps them.&lt;/p>
&lt;h2 id="how-to-walk-through-the-blog">How to walk through the blog&lt;/h2>
&lt;p>If you arrive at this post from outside and want a reading route:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The map&lt;/strong>: &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 of everything else.&lt;/li>
&lt;li>&lt;strong>The context&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps specific to LLMs in 2026&lt;/a> — the landscape and why LLMOps is not classic MLOps.&lt;/li>
&lt;li>&lt;strong>Inference from the inside out&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> → &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a> → &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a> → &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">Multi-tenant GPU cluster&lt;/a> → &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on K8s&lt;/a> → &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM operators on K8s&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Data&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning with DVC and lakeFS&lt;/a> → &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant ingestion&lt;/a> → &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Tune&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Eval&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> → &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Observe&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight LLM tracing&lt;/a> → &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability with OTel&lt;/a> → &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">On-device eBPF + drift&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Retrain&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Closing the feedback → dataset → adapter loop&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Cross-cutting&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Supporting infrastructure&lt;/strong> (the base everything is built on): &lt;a href="https://blog.lo0.es/en/posts/kubernetes-cilium-bgp-services-without-ingress/">RKE2 with Cilium BGP&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble + eBPF observability&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon runtime security&lt;/a>.&lt;/li>
&lt;/ol>
&lt;h2 id="what-we-have-not-covered-yet">What we have not covered (yet)&lt;/h2>
&lt;p>At the first level the main things are there. The following posts on the blog, when the topics justify it, could go deeper into:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Schema Registry for LLM data and prompts&lt;/strong>: the other half of the data contract.&lt;/li>
&lt;li>&lt;strong>A dedicated AI Gateway&lt;/strong>: LiteLLM, Portkey, Kong AI Gateway as a control plane.&lt;/li>
&lt;li>&lt;strong>OTel gen_ai semantic conventions&lt;/strong>: the emerging standard that ties the seven identifiers of the &amp;ldquo;identity&amp;rdquo; block into well-formed spans.&lt;/li>
&lt;li>&lt;strong>Federated learning over regulated customer data&lt;/strong>: how to train without centralising the corpus.&lt;/li>
&lt;li>&lt;strong>Capacity planning&lt;/strong> for shared multi-tenant clusters.&lt;/li>
&lt;li>&lt;strong>Disaster recovery&lt;/strong> of an LLM service: how to reproduce the state of the system 30 days back.&lt;/li>
&lt;li>&lt;strong>Cost accounting per tenant&lt;/strong>: tokens × weights × adapter × infrastructure → invoice.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">The parallel catalogue: the six LLMOps stages in open source and in the hyperscalers&lt;/a> — the vertical cut that complements this post: the same six stages + two cross-cutting components, but crossed with their equivalents in AWS, GCP and Azure, and with the insurer&amp;rsquo;s chatbot ported to an AWS stack.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages: entry by entry&lt;/a> — the zoom in on the open source side of the parallel catalogue: a ~150-word entry per core tool (vLLM, Langfuse, DVC, Qdrant, Airflow, NeMo Guardrails, Presidio…), licence and governance, a decision matrix per stage and a diagram of the connected OSS stack. It works as the consultant&amp;rsquo;s reference toolbox.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps specific to LLMs in 2026&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning for LLMOps&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant for ingestion&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka: technical architecture&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory of LLM inference&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention from the inside&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: the secretary who anticipates what the boss is going to say&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1/v2/v3/v4: the librarian who never clears the desk&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">The GPU cluster as a multi-tenant platform&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM operators on Kubernetes&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight: end-to-end LLM tracing&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and observability with OTel&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF in local inference and drift detection&lt;/a>&lt;/li>
&lt;li>&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>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kubernetes-cilium-bgp-services-without-ingress/">RKE2 with Cilium BGP&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble + eBPF observability&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon runtime security&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.w3.org/TR/trace-context/">W3C Trace Context&lt;/a> — end-to-end propagation of &lt;code>traceparent&lt;/code> and &lt;code>tracestate&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">OpenTelemetry GenAI Semantic Conventions&lt;/a> — &lt;code>gen_ai.*&lt;/code> attributes for LLM spans.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs">Langfuse documentation&lt;/a> — observability and prompt registry.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/">vLLM documentation&lt;/a> — production inference engine with PagedAttention and LoRA hot-swap.&lt;/li>
&lt;li>&lt;a href="https://kubernetes.io/docs/concepts/extend-kubernetes/operator/">Kubernetes Operators&lt;/a> — the declarative management pattern.&lt;/li>
&lt;li>&lt;a href="https://mlflow.org/docs/latest/">MLflow Tracking and Model Registry&lt;/a> — lineage of runs and input artifacts.&lt;/li>
&lt;li>&lt;a href="https://dvc.org/">DVC&lt;/a> and &lt;a href="https://lakefs.io/">lakeFS&lt;/a> — dataset versioning, unified in Nov 2025.&lt;/li>
&lt;li>&lt;a href="https://openlineage.io/">OpenLineage&lt;/a> — an open standard for lineage events between systems.&lt;/li>
&lt;li>ENS / NIS2: compliance frameworks that apply to operators in the EU; recommended reading for the context in which the scenario operates.&lt;/li>
&lt;/ul></description></item><item><title>Real ClickHouse capacity in Langfuse v4: 2,782 bytes per observation, and why almost half of that is not data</title><link>https://blog.lo0.es/en/posts/clickhouse-capacity-langfuse-v4-bytes-per-observation/</link><pubDate>Tue, 15 Sep 2026 09:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/clickhouse-capacity-langfuse-v4-bytes-per-observation/</guid><description>&lt;blockquote>
&lt;p>Sixth article in the series on operating self-hosted Langfuse v4. The previous five cover &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what goes into a trace&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">putting LangGraph in front&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/">two well-known agents instrumented&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">migrating with no window&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/">the worker queues&lt;/a>. This one measures what all of that takes up. Benchmark run on 15 September 2026 against the event schema of version 4.36.0, identical to that of 4.35.0.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Langfuse publishes no capacity figure at all.&lt;/strong> Not bytes per row, not events per second, not expected size. I searched their code with every reasonable combination and there is nothing. The only quantified thing is the commercial quotas of their managed service, which say nothing about disk.&lt;/p>
&lt;p>&lt;strong>One observation takes 2,782 bytes of disk.&lt;/strong> Split across the two event tables: 2,347 in the full table and 435 in the listings one. Measured over two hundred thousand observations shaped the way a real agent produces them.&lt;/p>
&lt;p>&lt;strong>41.8 % of the full table&amp;rsquo;s disk is not data, it is indexes.&lt;/strong> Four full-text indexes add up to 187 MiB against 260 MiB of data. The one covering the input costs 523 bytes per observation, almost three quarters of what the column it indexes costs.&lt;/p>
&lt;p>&lt;strong>The materialized view trims the text eight times over and saves eighteen per cent of disk.&lt;/strong> Truncating input and output to two hundred characters leaves the text at 64 MiB of the original 540, and even so the second table adds 18.5 % on top of the first. What survives the trimming is the seventy-eight-column skeleton and a metadata field that is truncated element by element, not as a whole.&lt;/p>
&lt;p>&lt;strong>In the listings table, metadata costs more than input and output together.&lt;/strong> 132.6 bytes per observation against 101.8. And its full-text index adds another 111.9, almost as much as the column.&lt;/p>
&lt;p>&lt;strong>A compression ratio quoted without saying what was compressed is worth nothing.&lt;/strong> My first pass, with repeated text, gave 38 times. With text of an entropy close to that of natural language it gives 3.68. Same schema, same indexes, same version.&lt;/p>
&lt;p>&lt;strong>Generations are 11 % of the observations and 65 % of the bytes.&lt;/strong> Their mean input is 15,655 bytes and the 95th percentile is 55,205, because each one reserialises the full history. It is the quadratic law from the second article in the series, landed on disk.&lt;/p>
&lt;p>&lt;strong>On deletion, the disk does not go down.&lt;/strong> A delete stops showing the rows and leaves the space occupied. A second step is needed, applying the deleted mask, and the cron that does it &lt;strong>ships disabled&lt;/strong>, just like the retention one. On top of that the cleaner works table by table, so the second table keeps the rows the first one no longer shows.&lt;/p>
&lt;p>&lt;strong>And automatic retention is a paid feature when self-hosted.&lt;/strong> The &lt;code>data-retention&lt;/code> entitlement is not in the open edition. Without it, the event tables grow without limit: they carry no expiry clause.&lt;/p>
&lt;h2 id="you-are-here-the-sizing-you-cannot-do-by-reading">You are here: the sizing you cannot do by reading&lt;/h2>
&lt;p>The previous five articles describe how version 4 works and how to put it to work. The moment arrives to ask for disk, and there the series runs out of sources.&lt;/p>
&lt;p>The official documentation gives a container sizing: two CPUs and eight gibibytes for ClickHouse, in Kubernetes eight of request and sixteen of limit, a hundred gibibytes of disk and three minimum replicas. It is a reasonable starting point and it does not answer the only question that matters, which is how long that disk lasts.&lt;/p>
&lt;p>I went looking for it in the code, which is what this series does. I searched for events per second, rows per observation, bytes per row, capacity, benchmark, sizing, across the whole repository and its documentation. &lt;strong>There is no figure at all.&lt;/strong> The closest thing is the ingestion quotas of their managed service, which are commercial API limits, and some working notes on a synthetic load generator that refer to the tables of the previous version and bring no results, only parameters.&lt;/p>
&lt;p>So the only honest way out was to measure it. This article is that measurement, with the whole method laid out so that anyone can repeat it and contrast it with their own load.&lt;/p>
&lt;h2 id="the-analogy-the-archive-and-the-index-card">The analogy: the archive and the index card&lt;/h2>
&lt;p>A municipal archive keeps case files. Each file has its folder with everything inside, and on top of that the archivist fills in an index card with the header data and the first lines of the subject, so that the file can be searched without pulling out the folder.&lt;/p>
&lt;p>Intuition says the cards take up nothing compared with the folders. And that is true if you look only at the paper. It stops being true when the archivist, in order to be able to search by any word, also builds a term file: a box with one card per distinct word appearing in any case file, pointing to where it occurs. That box grows with the vocabulary, not with the number of case files, and ends up taking almost as much room as the case files themselves.&lt;/p>
&lt;p>In this setup the folders are the full table, the cards are the listings table, and the term file is the full-text indexes. The article measures the three things separately, because whoever sizes by looking only at the folders falls short by almost half.&lt;/p>
&lt;h2 id="the-benchmark">The benchmark&lt;/h2>
&lt;p>I want this to be reproducible, so the method goes in whole.&lt;/p>
&lt;p>&lt;strong>The schema is the real one.&lt;/strong> I extracted the ClickHouse migration DDL from the repository, in its single-instance variant. There are not three migrations but six: the three that create the two tables and the materialized view, plus one that adds three ingestion attribution columns, another that adds an n-gram index over the metadata, and another that adds three evaluation columns and two indexes. The result is &lt;strong>78 columns per table, eleven skip indexes in the full table and fourteen in the listings one&lt;/strong>.&lt;/p>
&lt;p>I verified that this DDL is identical between the version I have pinned across the series and the current head of the repository: the diff of the migrations directory is empty.&lt;/p>
&lt;p>&lt;strong>The engine is ClickHouse 26.9.1.&lt;/strong> The minimum version 4 demands is 25.12, because of the text indexes the schema needs. I am using a newer version, and that is a difference from an installation that sticks to the minimum.&lt;/p>
&lt;p>&lt;strong>The data is synthetic and correctly shaped.&lt;/strong> The benchmark does not invent the rows: the columns and their typical values come from the worker function that builds the record. I write only into the full table, which is what the worker does, and let the materialized view populate the second one. That reproduces the real write amplification.&lt;/p>
&lt;p>The topology of each trace reproduces the law we measured in the second article of the series: four fixed observations plus five for each tool call. The number of calls per turn comes from a distribution with a low median and a long tail, capped at fifteen. Generations carry the full message history, which grows with the turn, because that is exactly what the instrumentation does and it is the origin of the quadratic growth in bytes.&lt;/p>
&lt;p>Result: 200,012 observations across 22,563 traces, that is &lt;strong>8.86 observations per trace&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>And the entropy of the text is calibrated, which is the part that almost ruined the benchmark.&lt;/strong> My first pass used a repeated paragraph as filler. With that text, the full table compressed 38.1 times and the figures came out beautiful and false. A repeated lorem ipsum is the best possible input for a compressor. I redid the generator with a wide vocabulary sampled from a Zipf distribution and fifteen per cent high-entropy identifiers, calibrated to give the ratio that natural language mixed with identifiers gives.&lt;/p>
&lt;p>With that text, the same table compresses 3.68 times. &lt;strong>Ten times less.&lt;/strong> Any ClickHouse capacity figure that does not say what was compressed is noise, and mine would be too without this paragraph.&lt;/p>
&lt;h2 id="what-one-observation-takes-up">What one observation takes up&lt;/h2>
&lt;p>The two tables, after forcing the final merge:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>events_full&lt;/th>
&lt;th>events_core&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Rows&lt;/td>
&lt;td>200,012&lt;/td>
&lt;td>200,012&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Uncompressed&lt;/td>
&lt;td>957.87 MiB&lt;/td>
&lt;td>328.73 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Compressed&lt;/td>
&lt;td>260.29 MiB&lt;/td>
&lt;td>60.34 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ratio&lt;/td>
&lt;td>3.68&lt;/td>
&lt;td>5.45&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>On disk&lt;/td>
&lt;td>447.68 MiB&lt;/td>
&lt;td>82.92 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Raw per observation&lt;/td>
&lt;td>5,022 B&lt;/td>
&lt;td>1,723 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Compressed per observation&lt;/td>
&lt;td>1,364.6 B&lt;/td>
&lt;td>316.3 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>On disk per observation&lt;/strong>&lt;/td>
&lt;td>&lt;strong>2,347 B&lt;/strong>&lt;/td>
&lt;td>&lt;strong>435 B&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The figure to take away is the last row added up: &lt;strong>2,782 bytes of disk per observation&lt;/strong>, counting both tables, with one replica and no retention.&lt;/p>
&lt;p>And it is worth looking at the distance between the last two rows. The compressed data of the full table is 1,364.6 bytes per observation, but on disk it takes 2,347. The difference, 982 bytes per observation, is the next section.&lt;/p>
&lt;h2 id="where-the-bytes-go">Where the bytes go&lt;/h2>
&lt;p>Broken down by column, with what each one costs per observation:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Column&lt;/th>
&lt;th>events_full&lt;/th>
&lt;th>events_core&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>input&lt;/code>&lt;/td>
&lt;td>717.5 B&lt;/td>
&lt;td>61.5 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>metadata_values&lt;/code>&lt;/td>
&lt;td>389.0 B&lt;/td>
&lt;td>132.6 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>output&lt;/code>&lt;/td>
&lt;td>174.7 B&lt;/td>
&lt;td>40.3 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>tool_definitions&lt;/code>&lt;/td>
&lt;td>28.0 B&lt;/td>
&lt;td>28.1 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>blob_storage_file_path&lt;/code>&lt;/td>
&lt;td>12.6 B&lt;/td>
&lt;td>12.5 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>span_id&lt;/code>&lt;/td>
&lt;td>8.1 B&lt;/td>
&lt;td>8.0 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The other 67 columns together&lt;/td>
&lt;td>17.3 B&lt;/td>
&lt;td>13.3 B&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three things jump out.&lt;/p>
&lt;p>The first is that &lt;strong>the skeleton is cheap&lt;/strong>. Sixty-seven columns, most of them empty in a normal observation, cost seventeen bytes between them all. ClickHouse compresses a column of empty strings down to almost nothing. Anyone who feared that the thirteen experiment columns or the telemetry ones would weigh can stop fearing it.&lt;/p>
&lt;p>The second is that in the full table &lt;strong>the input is half of everything&lt;/strong>. 717.5 of 1,364.6 compressed bytes. It is the direct consequence of each generation reserialising the history.&lt;/p>
&lt;p>The third is the one I was not expecting. In the listings table, &lt;strong>metadata costs more than input and output together&lt;/strong>: 132.6 against 101.8. The reason lies in how the materialized view truncates. It trims input and output to two hundred characters each, but it trims the metadata &lt;strong>element by element&lt;/strong>: an array with forty values can survive with eight thousand characters. The table that exists in order to be light fills up with the one thing its truncation does not control.&lt;/p>
&lt;h3 id="the-split-by-observation-type">The split by observation type&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Type&lt;/th>
&lt;th>Observations&lt;/th>
&lt;th>Mean input&lt;/th>
&lt;th>Input p95&lt;/th>
&lt;th>Mean output&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GENERATION&lt;/td>
&lt;td>21,952&lt;/td>
&lt;td>15,655 B&lt;/td>
&lt;td>55,205 B&lt;/td>
&lt;td>1,077 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AGENT&lt;/td>
&lt;td>22,563&lt;/td>
&lt;td>2,585 B&lt;/td>
&lt;td>6,013 B&lt;/td>
&lt;td>1,300 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TOOL&lt;/td>
&lt;td>21,952&lt;/td>
&lt;td>319 B&lt;/td>
&lt;td>319 B&lt;/td>
&lt;td>2,008 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CHAIN&lt;/td>
&lt;td>67,689&lt;/td>
&lt;td>413 B&lt;/td>
&lt;td>413 B&lt;/td>
&lt;td>20 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SPAN&lt;/td>
&lt;td>65,856&lt;/td>
&lt;td>272 B&lt;/td>
&lt;td>272 B&lt;/td>
&lt;td>206 B&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Generations are 11 % of the observations and contribute 65 % of the input and output bytes. The 95th percentile of their input is 55 kilobytes, in a benchmark where no conversation goes beyond fifteen tool calls.&lt;/p>
&lt;p>This has a sizing consequence worth saying out loud: &lt;strong>the size of the database is not determined by the number of requests, it is determined by the depth of the turns&lt;/strong>. Doubling the users doubles the disk. Doubling the tool calls per turn multiplies it by rather more than two.&lt;/p>
&lt;h2 id="the-indexes-which-are-almost-half">The indexes, which are almost half&lt;/h2>
&lt;p>Here is the finding that changes a sizing exercise the most.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Data&lt;/th>
&lt;th>Indexes&lt;/th>
&lt;th>% of disk in indexes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>events_full&lt;/td>
&lt;td>260.29 MiB&lt;/td>
&lt;td>187.31 MiB&lt;/td>
&lt;td>&lt;strong>41.8 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>events_core&lt;/td>
&lt;td>60.34 MiB&lt;/td>
&lt;td>22.49 MiB&lt;/td>
&lt;td>&lt;strong>27.1 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The marks and the primary key are irrelevant: thirty-two kilobytes and three hundred bytes respectively, with the granule size of 64 mebibytes the full table has declared.&lt;/p>
&lt;p>Broken down, and ordered by what they cost:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Index&lt;/th>
&lt;th>Table&lt;/th>
&lt;th>Bytes per observation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>idx_fts_input_low&lt;/code>&lt;/td>
&lt;td>events_full&lt;/td>
&lt;td>523.1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_fts_metadata_values&lt;/code>&lt;/td>
&lt;td>events_full&lt;/td>
&lt;td>319.4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_fts_output_low&lt;/code>&lt;/td>
&lt;td>events_full&lt;/td>
&lt;td>133.5&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_fts_metadata_values&lt;/code>&lt;/td>
&lt;td>events_core&lt;/td>
&lt;td>111.9&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_fts_metadata_names&lt;/code>&lt;/td>
&lt;td>both&lt;/td>
&lt;td>4.3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_span_id&lt;/code>&lt;/td>
&lt;td>both&lt;/td>
&lt;td>1.2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The remaining Bloom filters&lt;/td>
&lt;td>both&lt;/td>
&lt;td>0.1 each&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The full-text index over the input costs 523 bytes per observation when the column it indexes costs 717. That is, &lt;strong>indexing the input costs almost three quarters of what storing it costs&lt;/strong>. And the metadata one in the listings table costs 111.9 when its column costs 132.6: eighty-five per cent.&lt;/p>
&lt;p>The Bloom filters, on the other hand, are free as far as sizing goes. The four identifier ones and those for model name, experiment and evaluator add up to less than two bytes per observation between them all.&lt;/p>
&lt;p>This is not a criticism of the design. Those indexes are what make text search in the interface respond, and without them the experience would be another thing. It is sizing information that is published nowhere: &lt;strong>when asking for disk for ClickHouse you have to multiply what the columns say by 1.7.&lt;/strong>&lt;/p>
&lt;h2 id="the-materialized-view-as-an-amplifier">The materialized view as an amplifier&lt;/h2>
&lt;p>The second table exists so that listings do not have to touch the large fields. It truncates input and output to two hundred characters and does without the text indexes over them.&lt;/p>
&lt;p>The trimming works: the benchmark&amp;rsquo;s 540.55 MiB of input and output come down to 64.68 MiB, &lt;strong>eight and a half times less&lt;/strong>.&lt;/p>
&lt;p>And even so the second table adds 18.5 % of disk on top of the first. For the reasons already seen: the skeleton survives, the metadata truncated element by element survives, and its own text index over that metadata costs another 111.9 bytes.&lt;/p>
&lt;p>It is worth keeping in mind because it is counter-intuitive. You would think a table storing two hundred characters of text is an appendix. It is four hundred and thirty-five bytes per observation, almost a fifth of the total, and it is paid always, because the materialized view cannot be switched off without leaving the interface with no listings.&lt;/p>
&lt;h2 id="what-happens-on-deletion">What happens on deletion&lt;/h2>
&lt;p>The retention cron runs a lightweight delete. I reproduced it: I deleted the observations older than a date, which removed 67,395 rows of the 200,012.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Moment&lt;/th>
&lt;th>Visible rows&lt;/th>
&lt;th>Rows in the parts&lt;/th>
&lt;th>Disk&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Before&lt;/td>
&lt;td>200,012&lt;/td>
&lt;td>200,012&lt;/td>
&lt;td>447.68 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>After the delete&lt;/td>
&lt;td>132,617&lt;/td>
&lt;td>200,012&lt;/td>
&lt;td>447.68 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>After applying the mask&lt;/td>
&lt;td>132,617&lt;/td>
&lt;td>132,617&lt;/td>
&lt;td>299.11 MiB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The delete frees &lt;strong>not one byte&lt;/strong>. The rows stop being visible and the space stays occupied. The disk only goes down at the second step, applying the deleted mask, which frees 33.2 %.&lt;/p>
&lt;p>And that second step is run by a different cron that &lt;strong>ships disabled&lt;/strong>, just like the retention one. With the default values, whoever switches retention on in the interface will see the traces disappear from the view and will never see the disk go down, until an ordinary merge drags in the affected parts, which can take however long it takes.&lt;/p>
&lt;p>There is a third detail. The cleaner works &lt;strong>table by table&lt;/strong>, with an independent deleter for each one. In my test I deleted in the full table and the listings one kept its 200,012 rows, because the materialized view does not propagate deletes. In a real installation both deleters run, but they are two separate operations that can diverge if one fails.&lt;/p>
&lt;p>On top of this you have to add what the fourth article already covered: &lt;strong>neither of the two tables carries an expiry clause&lt;/strong>. There is no automatic expiry in the engine. The whole lifecycle depends on those crons. And per-project retention requires an enterprise edition entitlement, so in the open edition the only retention possible is the one you write yourself.&lt;/p>
&lt;h2 id="projection">Projection&lt;/h2>
&lt;p>With the measured 2,782 bytes per observation, one replica and no retention:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Observations per day&lt;/th>
&lt;th>Agent turns per day&lt;/th>
&lt;th>Per day&lt;/th>
&lt;th>Per month&lt;/th>
&lt;th>Per year&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>100,000&lt;/td>
&lt;td>~11,300&lt;/td>
&lt;td>0.26 GiB&lt;/td>
&lt;td>7.8 GiB&lt;/td>
&lt;td>94.6 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1,000,000&lt;/td>
&lt;td>~112,800&lt;/td>
&lt;td>2.59 GiB&lt;/td>
&lt;td>77.7 GiB&lt;/td>
&lt;td>945.6 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10,000,000&lt;/td>
&lt;td>~1,128,000&lt;/td>
&lt;td>25.91 GiB&lt;/td>
&lt;td>777.2 GiB&lt;/td>
&lt;td>9,456 GiB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The conversion to turns uses the benchmark&amp;rsquo;s 8.86 observations per trace, which depend on the depth of the agents. With deeper agents, the same turn figure produces more observations.&lt;/p>
&lt;p>And there is still a multiplication left. Langfuse recommends &lt;strong>three minimum replicas&lt;/strong> of ClickHouse in Kubernetes. With one million observations a day and three replicas, a year is &lt;strong>2.8 tebibytes of raw disk&lt;/strong>. The official sizing of a hundred gibibytes per replica covers about thirty-eight days at that rate.&lt;/p>
&lt;p>That is the number that is published nowhere and that has to go into the spreadsheet before committing to a service.&lt;/p>
&lt;h2 id="what-this-benchmark-does-not-measure">What this benchmark does not measure&lt;/h2>
&lt;p>For the sake of honesty, and because anyone repeating this is going to get different numbers:&lt;/p>
&lt;p>The data is synthetic. The shape of the row and the topology of the traces are taken from the code and from the measurements in the previous articles, but the text is generated by a sampler, not by a model. I have calibrated its entropy so that compression resembles that of natural language, and that calibration is the largest source of error in the whole article. A load with a lot of structured JSON will compress better; one with a lot of code or a lot of base64, worse.&lt;/p>
&lt;p>It is a single instance, with no replication. In a replicated installation there is a different engine and there are copies.&lt;/p>
&lt;p>It is ClickHouse 26.9.1, above the 25.12 minimum. The text index implementations have changed between versions and I have not compared.&lt;/p>
&lt;p>It does not measure performance: not ingestion per second, not query latency, not the CPU cost of merges. Disk only. The merge cost of full-text indexes is a topic with a life of its own and is left for the saturation runbook.&lt;/p>
&lt;p>And it does not measure object storage, which in version 4 is mandatory and keeps a copy of the original event. That is additional disk this benchmark does not touch.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Do not size using the official container sizing: it covers CPU and memory, not disk growth.&lt;/li>
&lt;li>Count &lt;strong>2,782 bytes per observation&lt;/strong> as a starting point, and adjust by measuring your own load.&lt;/li>
&lt;li>Multiply by the number of replicas before asking for the volume.&lt;/li>
&lt;li>Multiply what the columns suggest by 1.7, because the indexes are 41.8 % of the full table&amp;rsquo;s disk.&lt;/li>
&lt;li>Measure your own installation with the system tables instead of trusting this article: active parts for the total, columns for the breakdown, skip indexes for what is not data.&lt;/li>
&lt;li>Watch the metadata, which is the only thing the materialized view&amp;rsquo;s truncation does not control, because it trims each element and not the whole.&lt;/li>
&lt;li>Size by turn depth, not by number of requests.&lt;/li>
&lt;li>Switch on both crons, the retention one and the deleted mask one, and check that the disk really goes down after the second.&lt;/li>
&lt;li>Reckon on per-project retention requiring a licence, and budget the work of writing it if you do not have one.&lt;/li>
&lt;li>Remember that the tables have no expiry in the engine: with no cron, they grow for ever.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Langfuse publishes no capacity figure at all&lt;/strong>, neither in its code nor in its documentation.&lt;/li>
&lt;li>&lt;strong>A compression ratio without saying what was compressed means nothing&lt;/strong>: the same schema gives 38 times with repeated text and 3.68 with realistic text.&lt;/li>
&lt;li>&lt;strong>41.8 % of the full table&amp;rsquo;s disk is indexes&lt;/strong>, not data.&lt;/li>
&lt;li>&lt;strong>Indexing the input costs almost as much as storing it&lt;/strong>: 523 bytes per observation against 717.&lt;/li>
&lt;li>&lt;strong>The 78-column skeleton is irrelevant&lt;/strong>: seventeen bytes per observation across the sixty-seven small columns.&lt;/li>
&lt;li>&lt;strong>In the listings table metadata costs more than input and output together.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The materialized view&amp;rsquo;s truncation trims each element of the metadata, not the whole array.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The listings table is not an appendix&lt;/strong>: it adds 18.5 % of the first table&amp;rsquo;s disk despite storing two hundred characters.&lt;/li>
&lt;li>&lt;strong>A delete frees no disk.&lt;/strong> The mask has to be applied, and that cron ships disabled.&lt;/li>
&lt;li>&lt;strong>The retention one also ships disabled&lt;/strong> out of the box.&lt;/li>
&lt;li>&lt;strong>The cleaner works table by table&lt;/strong>; the materialized view does not propagate deletes.&lt;/li>
&lt;li>&lt;strong>The event tables carry no expiry clause.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Per-project retention is a paid feature&lt;/strong> when self-hosted.&lt;/li>
&lt;li>&lt;strong>Generations are 11 % of the observations and 65 % of the bytes.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Bloom filters are free&lt;/strong> as far as disk goes; the full-text ones are not.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>What I take from this benchmark is not the figure, although the figure was needed. It is that the split is not where you look for it.&lt;/p>
&lt;p>When somebody sizes a trace database, they look at the size of the prompts. It is the natural thing to do: they are the large fields and they are the ones you see in the interface. And it turns out that in this architecture the prompts are half the compressed data, and the compressed data is a little over half the disk. The rest is search structures nobody counts because they do not show up when you look at a row.&lt;/p>
&lt;p>There is a pattern there that goes beyond this tool. An observability database is, by definition, a database queried in unforeseen ways: by free text, by user, by session, by whatever is needed when something goes wrong at three in the morning. That ability to search by anything is paid for in disk, always, and it is paid at write time and not at search time. Langfuse&amp;rsquo;s design pays it fairly explicitly, with four full-text indexes declared in the migration, in plain sight for anyone who wants to read them.&lt;/p>
&lt;p>What was missing is for somebody to put the number alongside. Now it is there, with its margin of error and its method on the table, so that the next person to measure it can tell me where I got it wrong.&lt;/p>
&lt;h2 id="the-series-the-ten-articles">The series: the ten articles&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">What goes into a trace&lt;/a>: version 4&amp;rsquo;s data model, limits, precedences, scores, masking and indexes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">Putting LangGraph in front&lt;/a>: instrumenting an agentic platform and the measured cost of one turn.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/">Two well-known agents instrumented&lt;/a>: Open Deep Research and GPT Researcher, with the measured figures of a real request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">Migrating from version 3 to 4 with no window&lt;/a>: the three write-mode steps, the resumable background migrations and where the rollback point of no return sits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/">The worker queues&lt;/a>: the map of all thirty-nine, which pool to dedicate to each group, the per-queue switches, sharding and concurrency.&lt;/li>
&lt;li>Real ClickHouse capacity and cost (this article): how to measure bytes per observation with the system tables, the difference between the full table and the listings table, and the merge cost of full-text indexes.&lt;/li>
&lt;li>Retention, deletion and data protection: why a deletion does not free disk, the mask cleaner that ships disabled, the pending deletion queue and the S3 lifecycle that has to be implemented by hand.&lt;/li>
&lt;li>Backup and cross recovery: restore ordering across Postgres, ClickHouse and object storage, what each mismatch breaks, and how far event replay goes.&lt;/li>
&lt;li>Saturation runbook: what to alert on from the queue metrics, the stuck probes, draining through the readiness endpoint and the dead letter queue.&lt;/li>
&lt;li>Getting the data out: the blob storage integration to Parquet, batch exports and the metrics API, to build the data lake.&lt;/li>
&lt;/ol>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Self-hosted Langfuse: architecture and tuning&lt;/a> — the architecture of the previous version, where this setup comes from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — who produces the traces measured here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the other side of sizing, that of the gateway and the inference engine.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/deepagents-free-sdk-licensed-server/">deepagents on a cluster of your own&lt;/a> — the harness that generates these observations and where it keeps its own state.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for ENS, 42001 and the AI Act&lt;/a> — the framework where retention and deletion fit.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Langfuse, migraciones de ClickHouse (&lt;code>packages/shared/clickhouse/migrations/canonical/&lt;/code>, ficheros 0039 a 0047): &lt;a href="https://github.com/langfuse/langfuse">https://github.com/langfuse/langfuse&lt;/a>.&lt;/li>
&lt;li>Langfuse, escritura del worker en ClickHouse (&lt;code>worker/src/services/IngestionService/&lt;/code>, &lt;code>worker/src/services/ClickhouseWriter/&lt;/code>): &lt;a href="https://github.com/langfuse/langfuse">https://github.com/langfuse/langfuse&lt;/a>.&lt;/li>
&lt;li>Langfuse, limpiador de retención por lotes y limpiador de máscaras de borrado (&lt;code>worker/src/features/batch-data-retention-cleaner/&lt;/code>, &lt;code>worker/src/features/deleted-mask-cleaner/&lt;/code>): &lt;a href="https://github.com/langfuse/langfuse">https://github.com/langfuse/langfuse&lt;/a>.&lt;/li>
&lt;li>Langfuse, guía de autoalojamiento y requisitos de infraestructura: &lt;a href="https://langfuse.com/self-hosting">https://langfuse.com/self-hosting&lt;/a>.&lt;/li>
&lt;li>ClickHouse, tablas del sistema &lt;code>parts&lt;/code>, &lt;code>columns&lt;/code> y &lt;code>data_skipping_indices&lt;/code>: &lt;a href="https://clickhouse.com/docs/en/operations/system-tables">https://clickhouse.com/docs/en/operations/system-tables&lt;/a>.&lt;/li>
&lt;li>ClickHouse, índices de salto y de texto completo: &lt;a href="https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree">https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree&lt;/a>.&lt;/li>
&lt;li>ClickHouse, borrados ligeros y máscara de borrado: &lt;a href="https://clickhouse.com/docs/en/guides/developer/lightweight-delete">https://clickhouse.com/docs/en/guides/developer/lightweight-delete&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>deepagents on a cluster of your own: the SDK is free, the server is not, and what you have to build in between</title><link>https://blog.lo0.es/en/posts/deepagents-free-sdk-licensed-server/</link><pubDate>Tue, 15 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/deepagents-free-sdk-licensed-server/</guid><description>&lt;blockquote>
&lt;p>Opening of the agentic platform vertical. The previous articles dealt with the pieces separately: &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">the inference gateway&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">its second door&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">what a fleet of agents costs&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">isolation&lt;/a>. This one deals with what goes on top. Verified against &lt;code>deepagents&lt;/code> 0.7.14 and the LangGraph repository, commits of 14 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>deepagents is not a runtime.&lt;/strong> It is a factory that assembles middleware and delegates to LangChain&amp;rsquo;s agent factory. It returns a compiled LangGraph graph and nothing else. The repository&amp;rsquo;s own documentation says so without decoration: it introduces no new runtime. That is good news for whoever deploys it, because it runs wherever Python runs.&lt;/p>
&lt;p>&lt;strong>The planning tool no longer exists.&lt;/strong> It disappeared in 0.7.0 as a breaking change, along with the state channel that backed it. Almost everything written about this harness still describes it as one of its three legs. Today you have to ask for it explicitly, and you have to ask for it in each subagent too.&lt;/p>
&lt;p>&lt;strong>With the default configuration, the agent&amp;rsquo;s files live inside the checkpoint.&lt;/strong> The state backend stores the full content, binaries in base64, in the same place the conversation is persisted. Changing that is one line and it is the first architecture decision of the deployment.&lt;/p>
&lt;p>&lt;strong>The database grows per loop step, not per turn.&lt;/strong> Four tables, one row per superstep, one per channel and version, another per task write. And of the four cleanup operations the base class declares, the Postgres implementation has only one: delete an entire thread. The other three raise not-implemented.&lt;/p>
&lt;p>&lt;strong>Pruning by hand can empty the history silently.&lt;/strong> The code itself warns that a partial delete breaks the delta chain and leaves the channels rebuilding empty without raising any error. The only safe pruning is by whole thread.&lt;/p>
&lt;p>&lt;strong>Tenant isolation is the thread identifier, and there is no owner check.&lt;/strong> Whoever can send a thread identifier reads that thread. The on-disk filesystem backend fixes its root when the agent is built, so it separates nobody. And the documentation&amp;rsquo;s example for computing the per-user namespace does not work outside the managed platform.&lt;/p>
&lt;p>&lt;strong>There is a configuration trap that leaves a short-window model uncompacted.&lt;/strong> If the model does not expose its window, the compaction threshold falls back to 170,000 tokens. Served on a vLLM with a 32,768 window, the agent blows up on context length long before it compacts. The failure is silent and is fixed by passing the profile by hand.&lt;/p>
&lt;p>&lt;strong>Observability without the platform does exist, and it goes through a package that is not obvious.&lt;/strong> LangChain&amp;rsquo;s core does not emit OpenTelemetry: zero references. The exporter lives inside the LangSmith SDK, and there is a mode that emits over OTLP only, without talking to the service. It is the road to a Langfuse of your own, and it is worth knowing where it passes.&lt;/p>
&lt;p>&lt;strong>And here is where the free part ends: the server.&lt;/strong> The SDK, the graph engine and the checkpointers are MIT. The server that publishes them over HTTP carries an Elastic 2.0 licence, asks for a commercial licence key for production, adds Redis to the stack and reports metadata to an external endpoint unless there is an isolation agreement. The development tool is explicitly for development.&lt;/p>
&lt;p>&lt;strong>What is left is a list of things you have to write.&lt;/strong> Per-thread authorisation, retention, a run queue, cancellation, clean draining and migrations. None of them is hard. All of them are work, and none of them appears in the twenty-line front-page example.&lt;/p>
&lt;h2 id="you-are-here-the-harness-arrives-after-the-model">You are here: the harness arrives after the model&lt;/h2>
&lt;p>The sequence repeats on every project. First you set up inference, which is the visible part. Then the gateway, because you need keys and budgets. Then observability, because nobody knows what is going on. And when all of that works, somebody asks why the assistant cannot do multi-step tasks, and the word agent enters the scene.&lt;/p>
&lt;p>At that point the temptation is to write the loop by hand, and for two weeks it looks like a good idea. The loop is easy. What is not easy is everything you discover afterwards: what happens when the context fills up, where the intermediate files are stored, how you resume a task that was left half done, how you ask a human for confirmation without losing state.&lt;/p>
&lt;p>An agent harness is exactly that: the packaged answer to those questions. And choosing one looks like a library decision, about the size of choosing an HTTP client. It is not. It decides where your users&amp;rsquo; state lives, which database grows, what can be isolated and what cannot, and in the case at hand, whether there is a commercial licence waiting at the end of the road.&lt;/p>
&lt;p>This article looks at &lt;code>deepagents&lt;/code> with those eyes. It is not a usage guide, and I do not assess whether its agents are any good. I look at what it forces you to build.&lt;/p>
&lt;h2 id="the-analogy-the-scaffolding-and-the-crane">The analogy: the scaffolding and the crane&lt;/h2>
&lt;p>A building site needs scaffolding and it needs a crane, and they are not the same thing even though both are metal structures surrounding the building.&lt;/p>
&lt;p>You put up the scaffolding yourself with parts you bought. It adapts to the façade, it can be extended, and the day the work finishes you take it down and put it away. Nobody charges you for using it once it is bought.&lt;/p>
&lt;p>The crane is another matter. You hire it, it comes with an operator, it has a serial number and you have to notify somebody when it goes up. Without a crane the work still moves forward, more slowly and with more people, but it moves forward.&lt;/p>
&lt;p>Here the scaffolding is the SDK and the graph engine, and it belongs to whoever buys it. The crane is the server that exposes the agent over HTTP, and that one comes with a contract. This article is about where exactly the line between the two sits, because you cannot see it in the brochure, and about how much work can be done without a crane.&lt;/p>
&lt;h2 id="what-deepagents-actually-is">What deepagents actually is&lt;/h2>
&lt;p>The first thing to get out of your head is that it is a system. It is a function.&lt;/p>
&lt;p>The main factory assembles a list of middleware and calls LangChain&amp;rsquo;s agent factory, which is what builds the graph. What it returns is a compiled graph, with an added configuration on top that raises the recursion limit to a very high number and adds an integration tag in the metadata. The repository&amp;rsquo;s own architecture document states it: no new runtime is introduced.&lt;/p>
&lt;p>Compiled with the default options, the graph has the nodes you would expect from a reasoning-and-action loop: start, a prior node that repairs incomplete tool calls, the model node, the tools node and the end. The conditional edges run from the model to tools, to the model itself or to the end. There is nothing more. Everything that looks like a system is middleware that adds nodes only when it declares hooks of its own.&lt;/p>
&lt;p>The tools that ship by default are the filesystem ones, plus the subagent delegation one: list, read, write, edit, delete, search by pattern, search by content, execute, and the task tool.&lt;/p>
&lt;p>The good consequence of this is portability. If something is a LangGraph graph and nothing else, it runs in any Python container, with the checkpointer you choose, against the model you choose. There is no hidden service behind it.&lt;/p>
&lt;h3 id="what-is-no-longer-there">What is no longer there&lt;/h3>
&lt;p>It deserves its own section because it contradicts almost everything published. The planning tool, the one that wrote a task list and kept it in a state channel, &lt;strong>was removed in 0.7.0&lt;/strong> as a breaking change declared in the changelog. The tool is gone, the channel is gone, and the prompt fragment that went with it is gone.&lt;/p>
&lt;p>It survives as LangChain middleware, and you have to ask for it by hand. With one detail that bites: you have to ask for it in each subagent too, because each subagent assembles its own stack. All that remains from the previous stage are archaeological leftovers, such as the state key that is still on the list of keys excluded when passing context to a subagent.&lt;/p>
&lt;p>The plan, today, rests on files. Which is consistent with the rest of the design, and is another reason to look closely at where those files live.&lt;/p>
&lt;h2 id="where-state-lives">Where state lives&lt;/h2>
&lt;p>This is the first architecture decision and the one that is most expensive to get wrong.&lt;/p>
&lt;p>The agent&amp;rsquo;s state has two large channels. Messages, with a reducer of its own that deduplicates by identifier and treats deletions as tombstones. And files, with its own reducer where a null value means deleted. Both use a channel type that writes deltas and stores a full snapshot every fifty updates, and the code comment explains why: to take checkpoint growth from quadratic to linear with respect to the number of messages.&lt;/p>
&lt;p>That channel type is marked beta in its own code, with a warning that the on-disk representation may change. Worth knowing before you rest a platform on top of it.&lt;/p>
&lt;h3 id="the-backends">The backends&lt;/h3>
&lt;p>There is a common interface and several implementations. The ones that matter for deciding:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Backend&lt;/th>
&lt;th>Where the files live&lt;/th>
&lt;th>Persistence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>State&lt;/td>
&lt;td>Inside the graph state, that is, inside the checkpoint&lt;/td>
&lt;td>Per thread, in the checkpointer&amp;rsquo;s database&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Filesystem&lt;/td>
&lt;td>In a real directory anchored to a root&lt;/td>
&lt;td>Wherever you mount the volume&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Store&lt;/td>
&lt;td>In LangGraph&amp;rsquo;s store, with a namespace&lt;/td>
&lt;td>Crosses threads, with a time to live&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Composite&lt;/td>
&lt;td>Routed by path prefix among the above&lt;/td>
&lt;td>Mixed&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The default backend is the state one. That is: &lt;strong>by default, everything the agent writes ends up inside the checkpoint&lt;/strong>, and binaries go base64-encoded inside the same JSON. For a demo it is convenient, because you have nothing to set up. For a platform with real users it is a sizing decision taken by omission.&lt;/p>
&lt;p>There is a detail that rounds the matter off. The filesystem middleware automatically evicts large tool results to a file when they exceed a token threshold, so that they do not occupy the context window. It is a good mechanism. But with the state backend, evicting to a file means moving the content from one place in the checkpoint to another place in the same checkpoint. It leaves the model&amp;rsquo;s window and it does not leave the database.&lt;/p>
&lt;h3 id="what-grows-in-the-database">What grows in the database&lt;/h3>
&lt;p>The Postgres checkpointer creates four tables: one for migrations, one for checkpoints, one for blobs by channel and version, and one for writes by task. The primary keys all start with the thread identifier, and there is an index per table on that column.&lt;/p>
&lt;p>What you have to internalise is the rhythm. One checkpoint row is written &lt;strong>per engine superstep&lt;/strong>, not per conversation turn. A turn with four tool calls is several supersteps. None of that is recycled on its own.&lt;/p>
&lt;p>And here comes the uncomfortable part. The checkpointer&amp;rsquo;s base class declares four maintenance operations: delete a thread, delete by run, copy a thread and prune with a strategy. In the Postgres implementation &lt;strong>only the first is implemented&lt;/strong>. The other three inherit the not-implemented exception from the base class. Deleting a thread is three delete statements by identifier, and that is it.&lt;/p>
&lt;p>In other words: retention is yours to build. And with a care that the code itself documents and that is worth quoting in full, because it is the kind of warning you read after the incident. A naive pruning that removes intermediate checkpoints and their writes can cut the delta chain; the surviving checkpoint is rarely a snapshot point, so its channels would rebuild empty &lt;strong>without any error being raised&lt;/strong>.&lt;/p>
&lt;p>Translated into operations: the periodic sweep deletes whole threads and only whole threads. Never loose rows. And since the checkpoints table has no date column, to know which thread is old you have to keep count outside or rely on the checkpoint identifier being a time-sortable UUID.&lt;/p>
&lt;p>There is a variant of the checkpointer that stores only the last state and retains no history, which caps growth at the root. With one important reservation to verify before using it: it is not clear that it is compatible with the delta channels this harness uses by default for messages and files. Combining the two without checking is asking for a silent problem.&lt;/p>
&lt;h3 id="the-store-which-does-have-cleanup">The store, which does have cleanup&lt;/h3>
&lt;p>Curiously, the sibling piece is better resolved. The store has a configurable time to live, with refresh on read, skipping of expired entries and a sweep interval, and the sweep is a delete by expiry date.&lt;/p>
&lt;p>Two warnings. The sweeper is a thread inside the process, so with several replicas there are several sweepers competing for the same delete; it is idempotent, but it is free contention and you avoid it by switching it off and putting the delete in a scheduled cluster job. And the store&amp;rsquo;s semantic search requires the vector extension in the database, which constrains the managed Postgres image, which normally does not carry it.&lt;/p>
&lt;h2 id="the-isolation-that-does-not-exist">The isolation that does not exist&lt;/h2>
&lt;p>If the platform is going to have more than one tenant, this is the section that decides the deployment topology.&lt;/p>
&lt;p>The only thing separating one thread from another is the thread identifier. It is the first column of the primary key in all three tables. &lt;strong>There is no owner check anywhere in the checkpointer.&lt;/strong> Whoever manages to send a thread identifier in the call configuration reads that thread. Authorisation is the responsibility of the layer you write on top, and since that layer also has to be written, it is worth noting down now.&lt;/p>
&lt;p>By backend, the situation differs:&lt;/p>
&lt;ul>
&lt;li>The state one inherits thread isolation, which is enough if the HTTP layer validates that the user owns the thread.&lt;/li>
&lt;li>The store one computes the namespace per call, through a function that receives the runtime context. It is the correct option for multi-tenant.&lt;/li>
&lt;li>The filesystem one fixes its root &lt;strong>in the constructor&lt;/strong>, when the agent is built. There is no path in the code that recomputes it per request, per thread or per tenant. Sharing one deployment between tenants with this backend means everybody sees the same directory.&lt;/li>
&lt;/ul>
&lt;p>On that last point, do not confuse it with the protection that does exist. Virtual mode, which now comes enabled by default, blocks paths that escape the root. That protects against directory traversal. It does not protect against the neighbour. The project&amp;rsquo;s own threat document admits it: that mode exists to support the composite backend&amp;rsquo;s prefix routing, not as a security boundary.&lt;/p>
&lt;p>There is also a documentation trap worth flagging because it costs an afternoon. The example the documentation proposes for computing the per-user namespace reads the identity from a server-information field of the runtime context. That field is annotated in the LangGraph code as metadata injected by the managed server, and &lt;strong>null when running open-source LangGraph without managed deployments&lt;/strong>. On a cluster of your own with an HTTP layer of your own, that example fails. What you have to use is the context you fill in yourself, with the identifier that comes validated from the identity provider.&lt;/p>
&lt;p>And one more boundary that gets crossed without warning: the memory and skills middleware interpolate file content into the system prompt as is, without sanitising. If two tenants share a directory that serves as the source for that, the first writes instructions that the second&amp;rsquo;s agent executes. It is prompt injection through shared storage, and it is avoided with the same measure as everything above: do not share the filesystem backend between tenants.&lt;/p>
&lt;p>The topology conclusion is short. Real multi-tenant with files means &lt;strong>one deployment per tenant&lt;/strong>, with its namespace, its volume and its quota, or else the store backend with a namespace computed per request. There is no third way.&lt;/p>
&lt;h2 id="the-model-and-the-trap-you-need-to-know-about">The model, and the trap you need to know about&lt;/h2>
&lt;p>The default model is a specific commercial provider, and it is marked deprecated: passing an empty model warns and will stop working. You can inject any LangChain chat model, which is what anyone serving their own weights will do.&lt;/p>
&lt;p>With two integration details that are not obvious.&lt;/p>
&lt;p>The first. To point at an OpenAI-compatible endpoint, which is how a vLLM behind a gateway looks, you have to pass &lt;strong>the already-built instance&lt;/strong>, not the string with the provider prefix. With the string, a provider profile is applied that forces the use of the responses API, which a vLLM normally does not implement. Passing the built object applies no profile and the agent stays clean.&lt;/p>
&lt;p>The second is the trap, and in my view it is the most likely configuration failure in the whole deployment.&lt;/p>
&lt;p>The compaction middleware computes its thresholds from the model profile. If the model declares its input window, it compacts on reaching 85 % of the window and keeps 10 %. If it does not declare it, it falls back to a fixed value: &lt;strong>170,000 tokens&lt;/strong>.&lt;/p>
&lt;p>The chain that leads to not declaring it is the usual one in a deployment of your own. The OpenAI client resolves the profile by looking the model name up in a static table of the provider&amp;rsquo;s models. A model served as &lt;code>qwen3-30b-a3b&lt;/code> is not in that table, so the profile comes out empty, and empty becomes null. The method that assigns it swallows any exception, so there is no warning.&lt;/p>
&lt;p>Result: a vLLM with a 32,768-token window will not compact until 170,000. The server will return a context-length error long before that, and it will do so intermittently, only when conversations get long. There is a safety net that catches the error and trims, but that is reactive recovery after a failed call, not planning.&lt;/p>
&lt;p>The fix is one line, because the profile is a public field and only fills itself in when it is empty: you pass it when building the model, with a value below the real window to leave room for generation. And it deserves an &lt;code>assert&lt;/code> at start-up, because it is the kind of thing nobody looks at until it fails.&lt;/p>
&lt;p>A related sizing note. With the default thresholds and a 32,768 window, compaction would trigger at 27,852 tokens, while eviction of large tool results is fixed at 20,000. That is, a single tool result can occupy almost the whole budget before anything evicts it. With 128,000 windows the defaults are proportionate; below 64,000 you have to lower those two thresholds by hand.&lt;/p>
&lt;h2 id="the-tools-and-the-mcp-that-is-not-where-it-seems">The tools, and the MCP that is not where it seems&lt;/h2>
&lt;p>This harness has its filesystem tools and its delegation tool, and the rest are passed as an ordinary list of LangChain tools.&lt;/p>
&lt;p>The fact that matters to anyone with an MCP gateway in place: &lt;strong>the SDK does not integrate MCP&lt;/strong>. There is no reference to the protocol in the package, nor any dependency on adapters. The integration lives in the command-line tool that accompanies the project, which does depend on the MCP adapters for LangChain, resolves configurations in the style of the desktop clients, and turns each remote tool into a LangChain tool that then goes through the ordinary list.&lt;/p>
&lt;p>So the path exists and is proven, but you have to walk it yourself: load the gateway&amp;rsquo;s tools with the adapters and pass them in the list. Which, incidentally, fits well with &lt;a href="https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/">what we already know about the catalogue&lt;/a>: the credential-based filtering the gateway does is applied in the listing, so each agent receives the subset that belongs to it without the harness having to know anything.&lt;/p>
&lt;h3 id="the-subagents">The subagents&lt;/h3>
&lt;p>They are declared in three ways and invoked with a task tool that takes a description and a type. What you need to know to design with them:&lt;/p>
&lt;p>The subagent receives the parent&amp;rsquo;s state &lt;strong>except&lt;/strong> the messages and a few private keys, and it starts with a single human message which is the task description. Files &lt;strong>are&lt;/strong> inherited, and it shares the same backend object, so parent and child work on the same logical filesystem. The result comes back as a tool message with the structured response or the child&amp;rsquo;s last text.&lt;/p>
&lt;p>Depth is effectively one level. Verified by compiling the graph: the stack assembled for a declarative subagent does not include the subagent middleware, so the child does not have the task tool and cannot delegate in turn.&lt;/p>
&lt;p>And there is no concurrency limit of its own. The parallelism is that of the engine&amp;rsquo;s parallel tool calls, and the task tool&amp;rsquo;s prompt explicitly invites launching them in parallel. Anyone with a per-tenant GPU quota will want to put the brake where it belongs, because it is not here.&lt;/p>
&lt;h2 id="code-execution">Code execution&lt;/h2>
&lt;p>The sandbox protocol is well defined and the packaged implementations are nearly all paid services: four commercial providers, plus an embedded JavaScript interpreter and a sandbox from the platform vendor itself.&lt;/p>
&lt;p>That leaves a local shell backend that is worth looking at closely before considering it. It runs the command with the system shell, and its own docstring lists what it does not do: no isolation, no process separation, no resource limits, and it lists production and multi-tenant environments as inappropriate use cases. It also has an option to inherit the process environment which, enabled, hands the model every variable, including the gateway credentials and the database connection string. It comes disabled, and it is an easy foot to shoot.&lt;/p>
&lt;p>The reasonable way out on a cluster of your own is to implement the protocol yourself, and it is smaller than it looks. The sandbox base class requires four things: an identifier, execute, upload files and download files. Everything else, list, read, write, edit, delete and search, is built by the base class on top of execute. Looking at one of the commercial implementations, it is on the order of a hundred and fifty lines.&lt;/p>
&lt;p>One image requirement that constrains the Dockerfile and is worth knowing beforehand: the base class helpers inject small encoded Python scripts to resolve searches and checks, so the sandbox image &lt;strong>needs Python and a POSIX shell&lt;/strong> or half the calls fail.&lt;/p>
&lt;p>On how to implement it, the shape that fits a Kubernetes of your own is an ephemeral container per session with a bounded time to live, resource limits, no service account token mounted and a network policy that only allows egress towards the inference gateway. Execute is resolved against the cluster&amp;rsquo;s exec API, and upload and download through the same channel. The alternative with less code is an isolated runtime class for the agent pod, one pod per tenant, and the shell backend inside; you lose multi-tenancy in a single pod and you have nothing to write.&lt;/p>
&lt;h2 id="observability-and-the-package-it-goes-through">Observability, and the package it goes through&lt;/h2>
&lt;p>Here there is a fact that surprises and that is worth being clear about before designing the trace pipeline.&lt;/p>
&lt;p>&lt;strong>LangChain&amp;rsquo;s core does not emit OpenTelemetry.&lt;/strong> Zero references in the whole package. Its native tracer talks to the vendor&amp;rsquo;s observability service and that is that.&lt;/p>
&lt;p>The OTLP exporter does exist, but it lives inside that service&amp;rsquo;s SDK. And there is the good news: that SDK has a tracing mode that emits &lt;strong>only&lt;/strong> over OTLP, without talking to the service, reading the destination and headers from OpenTelemetry&amp;rsquo;s standard environment variables. The transport is HTTP with protobuf, which is exactly what a self-hosted Langfuse accepts on its ingestion route.&lt;/p>
&lt;p>That is: to take the agent&amp;rsquo;s traces to your own observability you have to install the vendor&amp;rsquo;s package and ask it not to talk to the vendor. It works, it is supported and it is documented, but it is a dependency you have to declare in the supply chain analysis, not a configuration detail. And if the OpenTelemetry packages are missing, it emits a warning and silently stops tracing, so start-up should check for it.&lt;/p>
&lt;p>There is the alternative of the callback handler that Langfuse itself publishes, with fewer pieces. The practical difference is correlation: over the OTLP route the agent&amp;rsquo;s spans share a trace identifier with the gateway&amp;rsquo;s and the inference engine&amp;rsquo;s, and you see a whole request end to end. Over the callback route, you do not. For anyone who already has a collector deployed, the OTLP route is the one that pays.&lt;/p>
&lt;h2 id="the-server-here-is-where-the-free-part-ends">The server: here is where the free part ends&lt;/h2>
&lt;p>Everything above is an architecture decision. This is a licence decision, and it is the one that gives the article its title.&lt;/p>
&lt;p>The harness SDK is MIT. The graph engine is MIT. The memory, SQLite and Postgres checkpointers are MIT. All of that runs anywhere, with no key, no registration and without calling anybody.&lt;/p>
&lt;p>The server that exposes a graph over HTTP is another matter. The package that implements it carries an &lt;strong>Elastic 2.0 licence&lt;/strong>, whose relevant clauses forbid offering the software to third parties as a managed service and forbid modifying or circumventing the licence key functionality. The command-line tool&amp;rsquo;s own command prints the conditions: for local development it asks for a service API key, and &lt;strong>for production use it asks for a licence key&lt;/strong> in an environment variable.&lt;/p>
&lt;p>There is also usage reporting. The server code contains a beacon endpoint to which it sends metadata, with a header carrying the licence key. The self-hosting documentation confirms it from the other side, listing among the requirements network egress to that domain for licence verification and usage reporting &lt;strong>if not running in isolated mode&lt;/strong>. Isolated mode is a contractual variant, not a box you tick.&lt;/p>
&lt;p>And it drags infrastructure along: besides Postgres, it asks for Redis.&lt;/p>
&lt;p>The development tool, which is the one that appears in every tutorial, describes itself as development mode with hot reload and an in-memory server. It is not a production server and it does not claim to be.&lt;/p>
&lt;p>None of this is a reproach. It is a legitimate and fairly common business model: permissive core, commercial control plane. What is not legitimate is finding out late. Anyone building a platform with a sovereignty requirement, with no dependencies on external services and with the argument that the whole stack is open, has to know that &lt;strong>the piece that turns the graph into a service does not meet that requirement&lt;/strong>.&lt;/p>
&lt;h2 id="what-you-have-to-build">What you have to build&lt;/h2>
&lt;p>The way out is to write the HTTP layer yourself, and the good news is that it is boring. A service that compiles the graph once with the checkpointer pointing at the database, and exposes an endpoint that validates the identity provider&amp;rsquo;s token, derives the thread identifier and the tenant context, and invokes the graph. Event streaming if needed.&lt;/p>
&lt;p>What you additionally have to write, and which the commercial server gave you ready-made:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Per-thread authorisation.&lt;/strong> The engine does not have it. It is the most important and the easiest to forget, because nothing fails if it is missing.&lt;/li>
&lt;li>&lt;strong>Retention and deletion.&lt;/strong> A scheduled job that deletes whole threads, never loose rows, for the reasons in the state section.&lt;/li>
&lt;li>&lt;strong>Migrations.&lt;/strong> Schema setup has to be called explicitly and needs data-definition permissions, so it goes in a separate job with a different role from the runtime one. And since it creates indexes concurrently, it cannot go through a connection pooler in transaction mode; point it at the direct write service.&lt;/li>
&lt;li>&lt;strong>Health probes and draining.&lt;/strong> The engine has a cooperative draining mechanism, but wiring it to the container&amp;rsquo;s termination signal is your own work.&lt;/li>
&lt;li>&lt;strong>Run queue and cancellation&lt;/strong>, if you need long background runs. This is where the absence is most felt, and the reasonable answer is a queue on the same database rather than another system.&lt;/li>
&lt;li>&lt;strong>Durability decided deliberately.&lt;/strong> There are three modes: persist before the next step, persist in parallel, or persist only on exit. The last one is no good on Kubernetes: if the pod dies, the whole turn is lost.&lt;/li>
&lt;/ul>
&lt;p>On that last point it is worth being explicit, because it affects tool design. The engine stores each task&amp;rsquo;s writes as soon as it finishes, and on resume it reapplies those that were already there and does not repeat them. A tool that finished is not run again. A tool &lt;strong>that was in flight when the pod died&lt;/strong> is, because its write never landed. The semantics are at-least-once, so every tool with an external effect has to be idempotent or carry an idempotency key. There is no exactly-once.&lt;/p>
&lt;p>And the same applies to interrupts for human confirmation: on resume, the node is &lt;strong>re-executed in full&lt;/strong>, so any side effect that sat before the interrupt inside the same node is repeated. The rule is that the interrupt goes first.&lt;/p>
&lt;h2 id="how-it-fits-with-activity-logging">How it fits with activity logging&lt;/h2>
&lt;p>Two things about this setup touch compliance head on, and both come out of earlier sections.&lt;/p>
&lt;p>The first is that the checkpoint is, de facto, a repository of personal data. It contains the whole conversation and, with the default backend, the files the agent has written. If the platform serves identifiable people, that database needs encryption, retention of its own and separate access control, exactly like the gateway&amp;rsquo;s spend table. The difference is that here there is no redaction switch that helps: the state is the state.&lt;/p>
&lt;p>The second is traceability. An agent that delegates to subagents and executes tools produces a chain of actions that you have to be able to reconstruct, and the only place where that chain is complete is the trace. Traces going out over OTLP to your own observability stops being a technical preference and becomes the logging mechanism. It is worth designing it that way from the start, and not adding it afterwards.&lt;/p>
&lt;p>As always in this series, the annex&amp;rsquo;s specific codes have to be checked against the current text before taking them into a compliance document; the approach is developed in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">the technical controls article&lt;/a>.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Decide the filesystem backend before the first demo, because the default one puts the content in the database.&lt;/li>
&lt;li>Pass the model profile by hand with the real window, and check it at start-up.&lt;/li>
&lt;li>Lower the eviction thresholds if the model&amp;rsquo;s window is below 64,000 tokens.&lt;/li>
&lt;li>Build the model as an instance, never as a string with a provider prefix, if there is an OpenAI-compatible endpoint behind it.&lt;/li>
&lt;li>Write per-thread authorisation before opening the service to more than one user.&lt;/li>
&lt;li>One deployment per tenant if you use the filesystem backend; a namespace computed per request if you use the store one.&lt;/li>
&lt;li>Compute that namespace from your own context, not from the server-information field, which is null outside the managed platform.&lt;/li>
&lt;li>A scheduled retention job that deletes whole threads and only whole threads.&lt;/li>
&lt;li>Schema migrations in a separate job, with a role of their own and against the direct write service.&lt;/li>
&lt;li>Synchronous durability, and tools with external effects made idempotent.&lt;/li>
&lt;li>Traces over OTLP with the mode that does not talk to the vendor&amp;rsquo;s service, and a start-up check that the packages are there.&lt;/li>
&lt;li>Put a concurrency limit on subagents if there is a GPU quota involved.&lt;/li>
&lt;li>If you need code execution, implement the sandbox protocol against an ephemeral container; the local shell backend is advised against by its own documentation.&lt;/li>
&lt;li>Note in the supply chain analysis the hard dependencies on commercial providers that the package drags in even when unused.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The planning tool was removed in 0.7.0.&lt;/strong> You have to add it by hand, and in each subagent too.&lt;/li>
&lt;li>&lt;strong>The default backend stores files inside the checkpoint&lt;/strong>, binaries included in base64.&lt;/li>
&lt;li>&lt;strong>Evicting a large result to a file does not get it out of the database&lt;/strong> with that backend, only out of the context window.&lt;/li>
&lt;li>&lt;strong>One checkpoint is written per superstep&lt;/strong>, not per turn.&lt;/li>
&lt;li>&lt;strong>Of the four declared cleanup operations, only delete-a-thread exists in Postgres.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>A partial pruning can empty the history without raising any error.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The delta channel is in beta&lt;/strong> and its on-disk representation may change.&lt;/li>
&lt;li>&lt;strong>There is no owner check in the checkpointer.&lt;/strong> The thread identifier is not a credential.&lt;/li>
&lt;li>&lt;strong>The filesystem backend&amp;rsquo;s root is fixed when the agent is built&lt;/strong> and it separates no tenants.&lt;/li>
&lt;li>&lt;strong>The documentation&amp;rsquo;s example for the per-user namespace does not work outside the managed platform.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The filesystem middleware and the subagent middleware cannot be removed.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>If the model does not declare its window, compaction does not trigger until 170,000 tokens&lt;/strong>, silently.&lt;/li>
&lt;li>&lt;strong>Passing the model as a string with a provider prefix forces an API that a vLLM does not implement.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The SDK does not integrate MCP&lt;/strong>; the integration lives in the command-line tool.&lt;/li>
&lt;li>&lt;strong>Subagents do not delegate&lt;/strong>: depth is one level.&lt;/li>
&lt;li>&lt;strong>LangChain&amp;rsquo;s core does not emit OpenTelemetry&lt;/strong>; the exporter lives in the vendor&amp;rsquo;s service SDK.&lt;/li>
&lt;li>&lt;strong>The HTTP server carries an Elastic 2.0 licence&lt;/strong>, asks for a licence key for production and reports usage unless there is an isolation agreement.&lt;/li>
&lt;li>&lt;strong>The development tool is for development&lt;/strong>, with an in-memory server.&lt;/li>
&lt;li>&lt;strong>Resumption is at-least-once&lt;/strong> for in-flight tools.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The question this article started with was whether &lt;code>deepagents&lt;/code> fits into a platform of your own. The answer is that it does, and that it fits better than I expected, precisely because it is less than it appears. A middleware stack on top of a graph is easy to host, easy to understand and easy to replace. What does not fit is the piece next to it.&lt;/p>
&lt;p>That asymmetry is the pattern I have been seeing all year across the ecosystem, and it deserves naming. The core is published under a permissive licence because the core is where you compete for adoption. The control plane is published under a restrictive licence because the control plane is where you charge. It happened with the gateways, it is happening with observability, and it happens here. Anyone building a sovereign platform cannot assess a whole project by the licence of its main repository: you have to look package by package, and look at which of them is the one you are going to need the day the thing has users.&lt;/p>
&lt;p>The good part is that the bill, in this case, is paid in work and not in money. What you have to build to do without the commercial server is an HTTP service with no mystery to it, a retention job, a migration job and an authorisation layer. None of that is research. All of it is a week of an engineer who knows what they are doing, and in exchange you are left with a system that deploys the same way in a school as in a classified datacenter, with no internet egress and with nobody counting runs on the other side.&lt;/p>
&lt;p>What I do recommend is making that list before the first demo and not after. Because the front-page example works in twenty lines, and the twenty lines have the default backend, the model without a profile and no authorisation at all.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing an inference gateway&lt;/a> — the piece underneath, and the same licence pattern seen somewhere else.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a> — where the tools this harness consumes come from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/">MCP tool priority&lt;/a> — how it is decided which subset of the catalogue each agent sees.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the real load a tool loop generates on the gateway and the engine.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key&lt;/a> — the network isolation this article takes as necessary.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">The second cost vector of agents&lt;/a> — durable execution and what a loop that fails halfway costs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for ENS, 42001 and the AI Act&lt;/a> — the framework where logging and retention fit.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the destination of the OTLP traces the article talks about.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;code>langchain-ai/deepagents&lt;/code>, código y documento de arquitectura: &lt;a href="https://github.com/langchain-ai/deepagents">https://github.com/langchain-ai/deepagents&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/deepagents&lt;/code>, registro de cambios de la rama del SDK (eliminación de la herramienta de planificación en la 0.7.0): &lt;a href="https://github.com/langchain-ai/deepagents/blob/main/libs/deepagents/CHANGELOG.md">https://github.com/langchain-ai/deepagents/blob/main/libs/deepagents/CHANGELOG.md&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/langgraph&lt;/code>, motor, canales y checkpointers: &lt;a href="https://github.com/langchain-ai/langgraph">https://github.com/langchain-ai/langgraph&lt;/a>.&lt;/li>
&lt;li>&lt;code>langgraph-checkpoint-postgres&lt;/code>, esquema de tablas y operaciones de mantenimiento: &lt;a href="https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres">https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres&lt;/a>.&lt;/li>
&lt;li>&lt;code>langgraph-checkpoint&lt;/code>, clase base del checkpointer y aviso sobre poda con canales de deltas: &lt;a href="https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint">https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint&lt;/a>.&lt;/li>
&lt;li>LangChain, despliegue de servidor autónomo (requisitos de licencia, Postgres, Redis y salida de red): &lt;a href="https://docs.langchain.com/langsmith/deploy-standalone-server">https://docs.langchain.com/langsmith/deploy-standalone-server&lt;/a>.&lt;/li>
&lt;li>LangChain, trazado con OpenTelemetry: &lt;a href="https://docs.langchain.com/langsmith/trace-with-opentelemetry">https://docs.langchain.com/langsmith/trace-with-opentelemetry&lt;/a>.&lt;/li>
&lt;li>Elastic License 2.0: &lt;a href="https://www.elastic.co/licensing/elastic-license">https://www.elastic.co/licensing/elastic-license&lt;/a>.&lt;/li>
&lt;li>Langfuse, integración nativa OpenTelemetry (ruta de ingesta y autenticación): &lt;a href="https://langfuse.com/integrations/native/opentelemetry">https://langfuse.com/integrations/native/opentelemetry&lt;/a>.&lt;/li>
&lt;li>Langfuse, integración con LangChain por retrollamadas: &lt;a href="https://langfuse.com/integrations/frameworks/langchain">https://langfuse.com/integrations/frameworks/langchain&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/helm&lt;/code>, chart del servidor: &lt;a href="https://github.com/langchain-ai/helm">https://github.com/langchain-ai/helm&lt;/a>.&lt;/li>
&lt;li>Boletín Oficial del Estado, Real Decreto 311/2022, Esquema Nacional de Seguridad: &lt;a href="https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191">https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Making the model prefer our tools: the priority that exists in no layer, the two primitives that do, and the `_meta` that crosses the gateway</title><link>https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/</link><pubDate>Tue, 15 Sep 2026 03:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/</guid><description>&lt;blockquote>
&lt;p>A follow-up to &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>, which treated the tool catalogue as cost and as surface. This one treats the next question, the one that arrives in the meeting afterwards: so then, how do I get the model to use mine. Verified against LiteLLM 1.102.0 and MCP specification &lt;code>2026-07-28&lt;/code>, with a test of our own run on 14 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Tool priority does not exist as a concept in any layer of the stack.&lt;/strong> Not in the specification, not in the clients, not in the providers&amp;rsquo; APIs. What exist are two distinct primitives: removing the alternative from the catalogue, and leaving it behind a search step. Everything else is persuasion, and the model ignores it when it suits.&lt;/p>
&lt;p>&lt;strong>The specification has no ranking field.&lt;/strong> The &lt;code>Tool&lt;/code> object is name, title, icons, description, schemas, five annotations that are unreliable hints by their own declaration, and &lt;code>_meta&lt;/code>. There is a trap I see repeated: MCP does define a numeric &lt;code>priority&lt;/code>, but in another structure, the one that applies to content blocks, resources and prompts. It does not govern tool selection.&lt;/p>
&lt;p>&lt;strong>The only filtering the specification blesses is by credential.&lt;/strong> The current revision says the set of tools must not vary by connection, and that it may vary according to the authorisation presented. That is exactly what a gateway with per-key lists does.&lt;/p>
&lt;p>&lt;strong>The client is the one that decides, and only one of them has anything resembling priority.&lt;/strong> Claude Code lets you exempt a server from deferral with &lt;code>alwaysLoad&lt;/code>, so that its tools load in full at start-up while the rest require a prior search. It is not called priority and it works as such.&lt;/p>
&lt;p>&lt;strong>We proved that the per-tool mark crosses the gateway.&lt;/strong> We set up an MCP server with one marked tool, served it through LiteLLM 1.102.0 and captured the wire: the &lt;code>_meta&lt;/code> arrives whole and with the correct key. The chain is clean on purpose, with a comment in the code that says so.&lt;/p>
&lt;p>&lt;strong>And it is lost on exactly three routes, which are the gateway&amp;rsquo;s three catalogue-trimming modes.&lt;/strong> Proxy mode, tool search driven by key permissions, and the REST listing. In other words: the per-tool mark and LiteLLM&amp;rsquo;s catalogue trimming are incompatible. You have to choose one.&lt;/p>
&lt;p>&lt;strong>There is a library trap that makes the field disappear silently.&lt;/strong> The SDK&amp;rsquo;s &lt;code>Tool&lt;/code> model declares the alias without allowing population by field name, so building it with the field name puts the dictionary in the extras and serialises it under the wrong key. There is no error. The field simply does not arrive.&lt;/p>
&lt;p>&lt;strong>The cheap lever is the description, not the name.&lt;/strong> Swapping descriptions shifts the selection distribution substantially; changing only the name has minimal and inconsistent effects. And the gateway&amp;rsquo;s overrides really do replace what is sent to the client.&lt;/p>
&lt;p>&lt;strong>Reordering the catalogue is a weak lever.&lt;/strong> The model is already attending to the correct tool eighty per cent of the time when it fails. Interventions on prompt order repair at most 23 % of the failures.&lt;/p>
&lt;p>&lt;strong>And the asymmetry you build is attack surface.&lt;/strong> Whoever controls the order controls the selection: with injection rates of 1.2 % you can take over the head of a tool ranking between 91 % and 97 % of the time.&lt;/p>
&lt;h2 id="you-are-here-the-question-that-arrives-after-the-third-server">You are here: the question that arrives after the third server&lt;/h2>
&lt;p>The previous article closed with a decision ladder for the catalogue: trim, rewrite, split by route, and use progressive disclosure when the catalogue grows. That ladder answers how much catalogue to show.&lt;/p>
&lt;p>The question that arrives afterwards is a different one, and it is more uncomfortable. A real agent has native tools from the client hosting it, it has the in-house gateway with the house servers, and it has two or three third-party servers that somebody connected. All of them compete. And what you want is not to show less catalogue: you want that, faced with a query three tools could serve, yours wins. The audited one, the one with access control in place, the one that leaves a trace.&lt;/p>
&lt;p>The answer everybody gives is to write a rule in the agent&amp;rsquo;s instruction file politely asking it to prefer the in-house server. There is even a rule published in a template marketplace that does exactly that. It is text in the prompt and the model follows it when it feels like it.&lt;/p>
&lt;p>This article is what lies underneath.&lt;/p>
&lt;h2 id="the-analogy-the-counter-with-two-trays">The analogy: the counter with two trays&lt;/h2>
&lt;p>Let us go back to the switchboard operator from the previous article, the one who also hands out keys. Now the building has three key providers: the house cabinet, the maintenance contractor&amp;rsquo;s, and the cleaning service&amp;rsquo;s. All three have a key to the store room and all three open it.&lt;/p>
&lt;p>The head of security wants the house one used, because it is the one with a log. And he discovers he can do three things, not one more.&lt;/p>
&lt;p>He can take the other two off the counter. It always works and it annoys whoever needed them.&lt;/p>
&lt;p>He can leave his own in the front tray and the other two in a drawer, so that to take one you have to ask. It works almost always and annoys nobody.&lt;/p>
&lt;p>And he can change the label on his key so that it describes better when it is the right one. It works sometimes, it is the cheapest, and it is the only one that also protects against the contractor changing his label without warning.&lt;/p>
&lt;p>What he cannot do is put a number from one to ten on each key and trust the operator to respect it. That number does not exist. The rest of the article is why it does not exist and what to do instead.&lt;/p>
&lt;h2 id="what-the-specification-says-nothing">What the specification says: nothing&lt;/h2>
&lt;p>The current revision is &lt;code>2026-07-28&lt;/code>. Read against the source schema, the &lt;code>Tool&lt;/code> object has name, title, icons, description, input schema, output schema, annotations and &lt;code>_meta&lt;/code>. There is no field for priority, weight, rank, cost, group or tag.&lt;/p>
&lt;p>The annotations are exactly five: title, and the read-only, destructive, idempotent and open-world hints. The schema itself warns that all of them are hints and that a client should not make tool-use decisions based on them when they come from untrusted servers.&lt;/p>
&lt;h3 id="the-priority-that-exists-and-is-no-use">The &lt;code>priority&lt;/code> that exists and is no use&lt;/h3>
&lt;p>Here is the mistake I see repeated most. MCP &lt;strong>does&lt;/strong> define a &lt;code>priority&lt;/code> field, numeric between zero and one. It is in the annotations interface that applies to content blocks, to resources and to prompts, alongside the audience and the last-modified date.&lt;/p>
&lt;p>It is not in &lt;code>Tool&lt;/code>. It has no relation whatsoever to which tool the model picks. Anyone who finds it by searching the word in the schema and concludes that tool priority is standardised has the wrong structure.&lt;/p>
&lt;p>The only precedence the specification defines for a tool is one of presentation: to display the name, first the title, then the annotations title, then the name.&lt;/p>
&lt;h3 id="the-only-blessed-filtering-is-by-credential">The only blessed filtering is by credential&lt;/h3>
&lt;p>There is a new sentence in the current revision that is genuinely useful and that goes unnoticed. The set of tools must not vary by connection nor as a side effect of other requests, and it may vary according to the authorisation presented in the request.&lt;/p>
&lt;p>Translated: segmenting the catalogue by key, by team or by token is the legitimate way to do it. Segmenting it by session state stopped being legitimate when the July revision removed protocol sessions.&lt;/p>
&lt;h3 id="discovery-does-not-discover-tools">Discovery does not discover tools&lt;/h3>
&lt;p>&lt;code>server/discover&lt;/code>, which the current revision adds and which servers have to implement, does not return tools. It returns supported versions, capabilities, server instructions, identity and the cache freshness fields. It accepts neither filters nor a cursor.&lt;/p>
&lt;p>Listing is still asking for everything and paginating. The listing request accepts only a cursor: there is no query, no filter, no limit, no groups, no tags.&lt;/p>
&lt;p>And on collisions between servers, the specification explicitly and reasonedly washes its hands: clients or proxies that aggregate tools from several servers will encounter collisions and should implement a disambiguation strategy, for example prefixing. And it adds that the server name is not guaranteed to be unique and should not be used to disambiguate. There is no precedence rule between servers. There is not going to be one soon.&lt;/p>
&lt;h3 id="what-there-is-in-proposals">What there is in proposals&lt;/h3>
&lt;p>Nothing accepted. The official proposals index only lists those in a final state, and none of them deals with priority, groups or search. In draft there is one asking for a free-text query in the listing and a filtering capability; another on groups and tags that ended up superseded; and one carrying the word priority but which is per-tool model routing, which is another thing.&lt;/p>
&lt;p>The interest group exploring the grouping of primitives declares in its own minutes that it is not going to pick a canonical pattern soon, and the documents in its repository are empty. The year&amp;rsquo;s roadmap does not mention the problem.&lt;/p>
&lt;h2 id="the-layer-that-decides-the-client">The layer that decides: the client&lt;/h2>
&lt;p>If the specification gives you nothing, what is left is whatever each client has built on its own. And here there is a clear winner.&lt;/p>
&lt;h3 id="claude-code">Claude Code&lt;/h3>
&lt;p>Tool search is on by default, and what it does is defer: MCP tools are not loaded in full at start-up, the model sees just enough and discovers them when it searches. It requires a model that supports tool reference blocks, that is Claude Sonnet 4.5, Haiku 4.5, Opus 4.5 and later.&lt;/p>
&lt;p>On top of that, the lever:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;mcpServers&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;gateway-casa&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;http&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;url&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;https://litellm.interna.svc/mcp&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;alwaysLoad&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The documentation describes it bluntly: if a server&amp;rsquo;s tools must always be visible without a search step, you set &lt;code>alwaysLoad&lt;/code> to true and all of that server&amp;rsquo;s tools are loaded into context at the start of the session, regardless of the global search setting. And it recommends using it for a small number of tools that the model needs on every turn, because each tool loaded up front consumes context.&lt;/p>
&lt;p>That is visibility asymmetry, and it works as priority even if it is not called that. Ours in front, the rest behind one step.&lt;/p>
&lt;p>There is an operational consequence that the documentation mentions and that matters more in a gateway than in an ordinary server: setting &lt;code>alwaysLoad&lt;/code> makes start-up &lt;strong>wait&lt;/strong> for that server to return its tools, capped by the standard five-second connection timeout. A gateway that aggregates eight upstream servers and does not cache the listing, as is the case, lists live against all eight every time. If one of them is slow, the session&amp;rsquo;s start-up feels it.&lt;/p>
&lt;p>There is also the fine-grained variant. An MCP server can mark individual tools as always loaded by including &lt;code>&amp;quot;anthropic/alwaysLoad&amp;quot;: true&lt;/code> in the tool&amp;rsquo;s &lt;code>_meta&lt;/code> object, with the same effect for that tool alone. That would let you load three tools from our gateway up front and leave the other forty from the same gateway deferred. It is the lever that matters, and it is the one that motivated the test in the next section.&lt;/p>
&lt;p>The rest of this client&amp;rsquo;s arsenal is pure exclusion, and it is worth knowing one detail that decides whether it works or not. A deny rule with the bare tool name &lt;strong>removes it from the model&amp;rsquo;s context&lt;/strong>; with parentheses, it does not. The documentation puts it in a table: plain &lt;code>WebFetch&lt;/code> removes the tool and the model cannot search at all, whereas &lt;code>WebFetch(domain:*)&lt;/code> keeps the tool and rejects every request. The difference is not cosmetic: in the second case the model still sees the tool, still tries it and still spends the context its definition occupies.&lt;/p>
&lt;p>The &lt;code>--tools&lt;/code> flag is the scalpel: it accepts the empty string to disable all the built-ins, and it does not affect MCP tools. That is, &lt;code>--tools &amp;quot;&amp;quot;&lt;/code> leaves the model with only what the gateway serves. It is a command-line flag and has no equivalent as a settings-file key; the functional equivalent there is deny rules.&lt;/p>
&lt;h3 id="the-other-clients">The other clients&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Client&lt;/th>
&lt;th>Disable the native ones&lt;/th>
&lt;th>Filter the MCP ones&lt;/th>
&lt;th>Priority&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Claude Code&lt;/td>
&lt;td>&lt;code>--tools &amp;quot;&amp;quot;&lt;/code>, deny by bare name&lt;/td>
&lt;td>deny by pattern, subagents&lt;/td>
&lt;td>&lt;code>alwaysLoad&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Codex CLI&lt;/td>
&lt;td>&lt;code>features.shell_tool&lt;/code> to false&lt;/td>
&lt;td>&lt;code>enabled_tools&lt;/code> and &lt;code>disabled_tools&lt;/code> per server&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Gemini CLI&lt;/td>
&lt;td>&lt;code>tools.core&lt;/code> as an allowlist&lt;/td>
&lt;td>&lt;code>includeTools&lt;/code> and &lt;code>excludeTools&lt;/code> per server&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Copilot in VS Code&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>picker and tool sets&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cursor&lt;/td>
&lt;td>No&lt;/td>
&lt;td>per-server switches&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two notes. Copilot cuts off at 128 tools per request and its virtualisation threshold does not go above that; its embedding-based routing is internal and has no configuration surface. And Cursor&amp;rsquo;s rules are purely indicative: they are text prepended to the context.&lt;/p>
&lt;h2 id="the-api-layer">The API layer&lt;/h2>
&lt;p>If the client is in-house, the margin is wider, though narrower than it looks.&lt;/p>
&lt;p>&lt;strong>Forcing a set is not possible.&lt;/strong> In Anthropic&amp;rsquo;s case, tool choice accepts auto, any, a specific one by name, or none, and the documentation says explicitly that pointing at a set of MCP tools or at a member is not supported. In vLLM it is the same: a specific one, all of them or none. There is no way to say &amp;ldquo;any of mine from the gateway&amp;rdquo;. The proposal that would introduce grammars restricting tool names to the request&amp;rsquo;s set has been open for months and is not merged.&lt;/p>
&lt;p>&lt;strong>Filtering the catalogue is possible, in all three of the big ones.&lt;/strong> In Anthropic, with an MCP tool-set block where the default configuration disables and specific ones are enabled. In OpenAI, with the allowed tools list inside the MCP block itself, which the documentation justifies on latency and cost grounds, avoiding the model seeing unnecessary definitions. And in Gemini, the remote MCP server configuration accepts restricting which of the server&amp;rsquo;s tools the agent may call.&lt;/p>
&lt;p>It is worth not confusing two things that are named almost identically in OpenAI: the allowed tools list &lt;strong>inside&lt;/strong> the MCP block filters the catalogue, and that is what matters here; the identically named tool-choice type is another thing, it is a selection restriction, and there is evidence that it does not combine well with hosted tools, without the current documentation recording it. If anyone depends on that, let them test it against their model: it fails as a request error, so it shows up on a cold start.&lt;/p>
&lt;p>&lt;strong>And there is a 2026 lever that almost nobody is using.&lt;/strong> Anthropic has in beta a mechanism for mid-conversation tool changes, with add and remove blocks that accept referencing an entire MCP set. The reason it exists is in its documentation: the tools array sits even earlier in the chunked prefix than the system field, so editing it invalidates the cache for the whole conversation; by declaring the full set at the start and using the blocks, the array never changes and the cached prefix stays intact.&lt;/p>
&lt;p>Applied to our case: you can remove a rival server&amp;rsquo;s set hot, mid-session, without paying for the whole prefix. It is not available on every model.&lt;/p>
&lt;h2 id="the-test-the-_meta-crosses-the-gateway">The test: the &lt;code>_meta&lt;/code> crosses the gateway&lt;/h2>
&lt;p>The fine-grained variant of Claude Code&amp;rsquo;s lever depends on a question no documentation answers: whether a gateway that aggregates upstream servers propagates each tool&amp;rsquo;s &lt;code>_meta&lt;/code> all the way to the client, or loses it along the way.&lt;/p>
&lt;p>We checked.&lt;/p>
&lt;h3 id="the-setup">The setup&lt;/h3>
&lt;p>A minimal MCP server with the Python SDK, serving two tools over HTTP, one of them with the mark in place:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">types&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Tool&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">**&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;always_loaded_tool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;tool with _meta&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;inputSchema&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;properties&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{}},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;_meta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;anthropic/alwaysLoad&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;probe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;UPSTREAM_META_MARKER&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In front of it, a LiteLLM from the main branch with that server declared in &lt;code>config.yaml&lt;/code>, with no database, with a master key. And an MCP client querying the listing against &lt;code>/mcp&lt;/code>.&lt;/p>
&lt;h3 id="the-result">The result&lt;/h3>
&lt;p>This is what comes out on the wire:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;_meta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;litellm.ai/server_outcomes&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;probe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;status&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;tool_count&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="p">}}},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;tools&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;probe-always_loaded_tool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;tool with _meta&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;inputSchema&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;properties&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{}},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;_meta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;anthropic/alwaysLoad&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;probe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;UPSTREAM_META_MARKER&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The mark arrives whole and with the correct key. The client parses it without trouble.&lt;/p>
&lt;p>Tracing the code, the chain is clean on purpose. LiteLLM keeps the SDK objects as they are when it lists against the upstream, instead of rebuilding them. The name prefixing does a deep copy and mutates only the name, with a comment in the code declaring the intention to preserve every field including the &lt;code>_meta&lt;/code> by avoiding mutation. The two permission filters are list comprehensions that reuse the same objects. And the name and description overrides mutate in place, so they do not lose anything either. Somebody thought about this.&lt;/p>
&lt;p>One operational detail: the name arrives prefixed with the server alias, governed by the configurable separator and the short prefix mode.&lt;/p>
&lt;h3 id="the-three-routes-where-it-is-lost">The three routes where it is lost&lt;/h3>
&lt;p>And here is the finding that changes the previous article&amp;rsquo;s recommendation.&lt;/p>
&lt;p>Proxy mode returns only its three fixed tools, with no &lt;code>_meta&lt;/code> and with no upstream tool at all. Verified against the wire as well.&lt;/p>
&lt;p>Tool search activated by key permissions does the same with four virtual tools.&lt;/p>
&lt;p>And the REST listing builds its response object by hand and discards the field: the tool that did carry a mark comes out with the field null. The class inherits from the type that has the field; it simply is not filled in. The fix fits on one line.&lt;/p>
&lt;p>Those three routes are, exactly, the three ways LiteLLM has of trimming the catalogue. The operational conclusion is uncomfortable and it is worth saying plainly: &lt;strong>either you use the gateway&amp;rsquo;s catalogue trimming, or you use the client&amp;rsquo;s per-tool mark. They cannot be combined.&lt;/strong> The previous article recommended proxy mode as the fifth rung of the ladder; with this in hand, that rung and the fine-grained mark are mutually exclusive.&lt;/p>
&lt;h3 id="the-library-trap">The library trap&lt;/h3>
&lt;p>This deserves its own section because it takes out anyone writing a server or a proxy, and it gives no warning.&lt;/p>
&lt;p>The SDK&amp;rsquo;s tool model declares the field with an alias, and does not enable populating it by name. The consequences, both checked by running them:&lt;/p>
&lt;p>Building with the field name &lt;strong>populates nothing&lt;/strong>. The dictionary slips into the model&amp;rsquo;s extras and comes out serialised under the wrong key, without the leading underscore, which is a key no client interprets.&lt;/p>
&lt;p>And dumping and revalidating without asking for aliases &lt;strong>loses the field&lt;/strong>, for the same reason.&lt;/p>
&lt;p>There is no exception, no warning in the log, nothing. The field disappears. If someone marks their tools and does not see them marked in the client, this is the first place to look, before the gateway.&lt;/p>
&lt;h3 id="what-we-have-not-tested">What we have not tested&lt;/h3>
&lt;p>For honesty&amp;rsquo;s sake, and because it is the link that remains: we have shown that the mark survives the gateway. We have not shown that the client acts on it when the tool arrives with the name prefixed by the gateway. Claude Code&amp;rsquo;s documentation describes the per-tool mark in a single sentence and says nothing about aggregating servers or about prefixes.&lt;/p>
&lt;p>It is a half-hour test for anyone who has the setup in front of them: two tools from the same gateway, one marked, and look at which one appears loaded at the start of the turn. If someone does it before I do, I am interested in the result.&lt;/p>
&lt;h2 id="what-litellm-can-and-cannot-do">What LiteLLM can and cannot do&lt;/h2>
&lt;p>With the above, the gateway&amp;rsquo;s inventory of levers comes out like this.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Lever&lt;/th>
&lt;th>Serves external MCP clients&lt;/th>
&lt;th>Preserves the &lt;code>_meta&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>allowed_tools&lt;/code> and &lt;code>disallowed_tools&lt;/code> per server&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Name and description overrides&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tool sets per route&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Proxy mode and virtual tools&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Search by key permissions&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>REST listing&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Semantic filter&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Not applicable&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two clarifications on the previous article&amp;rsquo;s table, now that the code has been read again.&lt;/p>
&lt;p>The allowed and disallowed lists &lt;strong>are applied in the listing&lt;/strong>, not only on the call. They are the blunt, effective instrument, and they do not break the cache because they are static.&lt;/p>
&lt;p>And the overrides have a limit that was not accounted for: they are applied in the MCP protocol listing, but they are &lt;strong>skipped in proxy mode and not applied on the REST route&lt;/strong>. Anyone rewriting descriptions and also turning on proxy mode is not serving what they think.&lt;/p>
&lt;p>What the gateway cannot do is inject &lt;code>_meta&lt;/code>. There is no setting equivalent to the ones for name and description. The mark has to be put there by the origin server, which for in-house servers is trivial because we write them. To mark third-party tools you would have to patch, and it is small: one field in the type and three lines in the overrides function if configuring it by file is enough.&lt;/p>
&lt;p>There is also an ordering lever that did not appear in the previous article. In the proxy&amp;rsquo;s search mode there is a core tools setting that puts them at the front of the ranking and that also does not count against the results cap. It is the closest thing to a declarative priority in the whole stack, and it lives inside the one mode that discards the &lt;code>_meta&lt;/code>.&lt;/p>
&lt;p>And a nuance about proxy mode&amp;rsquo;s results cap. The client cannot ask for more than five, that is true, but the operator can raise it by configuration. The previous article implied it was immovable.&lt;/p>
&lt;h2 id="the-cheap-lever-the-description-not-the-name">The cheap lever: the description, not the name&lt;/h2>
&lt;p>If you cannot exclude, what is left is to bias. And bias comes in through the description.&lt;/p>
&lt;p>The work that measures selection bias between functionally equivalent tools puts the combined bias of the evaluated models between 0.25 and 0.38, that is, you would have to redistribute between 25 % and 38 % of the probability mass for equivalent tools to be picked equally. On that basis, swapping two tools&amp;rsquo; descriptions shifts selection substantially, whereas perturbations that touch only the name produce smaller and more inconsistent effects.&lt;/p>
&lt;p>Another piece of work measures that a single pass of automatic description rewriting improves the metric over a large corpus, almost as much as iterative refinement, and with two orders of magnitude less time. And it adds a warning worth retaining: descriptions tuned against a fixed set of candidates do not generalise to the dynamically retrieved set. You have to optimise for the regime you serve in.&lt;/p>
&lt;p>Applied: the gateway&amp;rsquo;s description override is at once token trimming, selection bias and mitigation of description change after approval. It is the lever with the best effort-to-effect ratio in the whole article, and it is static, so it does not touch the prompt cache.&lt;/p>
&lt;p>Name prefixing, by contrast, costs tokens and probably does not change which tool the model goes to.&lt;/p>
&lt;h2 id="what-does-not-work">What does not work&lt;/h2>
&lt;p>&lt;strong>Reordering the catalogue.&lt;/strong> A June paper measures that, when the model fails, it was already attending to the correct tool eighty per cent of the time, well above chance. The bottleneck is not at the input but in the late layers. Interventions on prompt order recover at most 23 % of the failures, against the 59 % to 91 % of those acting on the final readout. Reordering is cheap and that is why it gets recommended a lot; it is also weak.&lt;/p>
&lt;p>&lt;strong>Rules and instructions.&lt;/strong> They are text. They help and they do not decide.&lt;/p>
&lt;p>&lt;strong>Your own per-request top-K filters.&lt;/strong> This is covered in the previous article and it still holds: four out of five measured strategies perform worse than not filtering, and on top of that rewriting the tool block on every turn invalidates the entire cached prefix.&lt;/p>
&lt;h2 id="the-ladder-updated">The ladder, updated&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Exclude in the client.&lt;/strong> It is the only deterministic thing. &lt;code>--tools &amp;quot;&amp;quot;&lt;/code> or deny by bare name for the competing native tools.&lt;/li>
&lt;li>&lt;strong>Mark your own as always loaded.&lt;/strong> At server level with &lt;code>alwaysLoad&lt;/code>, or per tool with the mark in the &lt;code>_meta&lt;/code> if you control the origin server. Counting on start-up waiting for the server.&lt;/li>
&lt;li>&lt;strong>Trim with static lists&lt;/strong> per server and per key, and split a catalogue by agent profile across routes.&lt;/li>
&lt;li>&lt;strong>Rewrite the descriptions&lt;/strong> with the gateway&amp;rsquo;s overrides.&lt;/li>
&lt;li>&lt;strong>Remove hot&lt;/strong> the rival sets, if the client is in-house and the model supports it.&lt;/li>
&lt;li>&lt;strong>Choose&lt;/strong>: the gateway&amp;rsquo;s catalogue trimming, or the per-tool mark. Not both.&lt;/li>
&lt;li>&lt;strong>Do not&lt;/strong>: reorder, rename, or trust prompt rules.&lt;/li>
&lt;/ol>
&lt;h2 id="the-risk-that-has-to-be-declared">The risk that has to be declared&lt;/h2>
&lt;p>The asymmetry you build is also a surface. Whoever controls which tools head a ranking controls the selection, and that can be attacked: there is work measuring that by injecting adversarial tools at rates of 1.2 % you take over the head of the ranking between 91 % and 97 % of the time.&lt;/p>
&lt;p>In a system under ENS (Esquema Nacional de Seguridad, Spain&amp;rsquo;s national security framework) this fits under change management, not access control: the set of tools a model sees and the order in which it sees them are security configuration, and today nobody versions them. Neither does the specification fix a hash of the description or of the schema, nor does the gateway. The specific Annex II codes are worth checking against the current text before taking them into a compliance document; the approach is developed in &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">the MCP gateway article&lt;/a>.&lt;/p>
&lt;p>There is a precedent within the ecosystem itself that points the way. The MCP skills extension, which is in a final state, requires a manifest with a per-file hash, mandatory verification before use, and approval tied to the set of files and their digests, so that any change revokes the approval. And it obliges hosts to prevent two skills with the same name from silently replacing one another. It is exactly the pattern missing for tools. Anyone who needs it today has to implement it in their gateway.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Decide the strategy before touching anything: catalogue trimming in the gateway, or the per-tool mark in the client. They are mutually exclusive.&lt;/li>
&lt;li>If you pick the mark, put it in the origin server, because the gateway cannot inject it.&lt;/li>
&lt;li>Build the tool object with the alias key, never with the field name, or the field disappears without warning.&lt;/li>
&lt;li>Do not revalidate tool objects from a dump without asking for aliases.&lt;/li>
&lt;li>Count on marking a server as always loaded making session start-up wait for that server, and on a gateway listing live against all of its upstreams.&lt;/li>
&lt;li>Exclude in the client the native tools that compete, by bare name and not with a pattern in parentheses.&lt;/li>
&lt;li>Rewrite the descriptions of your own tools, and do not waste time renaming.&lt;/li>
&lt;li>Do not turn on proxy mode if you depend on the description overrides, because they are not applied there.&lt;/li>
&lt;li>Version the exposed tool set and its order as security configuration, with a hash of the description and the schema.&lt;/li>
&lt;li>If you use the gateway&amp;rsquo;s REST route for anything, know that it discards the &lt;code>_meta&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>MCP&amp;rsquo;s &lt;code>priority&lt;/code> exists, and it is not about tools.&lt;/strong> It lives in the annotations for content, resources and prompts.&lt;/li>
&lt;li>&lt;strong>Tool annotations are hints and the schema itself says they are not reliable&lt;/strong> from untrusted servers.&lt;/li>
&lt;li>&lt;strong>The specification gives no precedence rule between servers&lt;/strong>, and it says besides that the server name is no use for disambiguating.&lt;/li>
&lt;li>&lt;strong>The tool set may indeed vary by authorisation&lt;/strong>, and that is the only blessed segmentation.&lt;/li>
&lt;li>&lt;strong>&lt;code>server/discover&lt;/code> does not return tools.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>A deny rule with parentheses does not remove the tool from the context&lt;/strong>, it only rejects the calls.&lt;/li>
&lt;li>&lt;strong>&lt;code>--tools&lt;/code> is a command-line flag&lt;/strong>, with no equivalent in the settings file.&lt;/li>
&lt;li>&lt;strong>Marking a server as always loaded delays start-up&lt;/strong> by up to five seconds per server.&lt;/li>
&lt;li>&lt;strong>&lt;code>Tool(meta=...)&lt;/code> does not populate the field&lt;/strong> and serialises it under the wrong key.&lt;/li>
&lt;li>&lt;strong>Proxy mode, search by permissions and the REST listing discard the &lt;code>_meta&lt;/code>.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Description overrides are skipped in proxy mode&lt;/strong> and are not applied on REST.&lt;/li>
&lt;li>&lt;strong>Proxy mode&amp;rsquo;s cap of five results can be raised by the operator&lt;/strong>, even though the client cannot ask for more.&lt;/li>
&lt;li>&lt;strong>The core tools of the proxy&amp;rsquo;s ranking are the only declarative priority in the stack&lt;/strong>, and they live in the mode that loses the &lt;code>_meta&lt;/code>.&lt;/li>
&lt;li>&lt;strong>You cannot force &amp;ldquo;any of mine from the gateway&amp;rdquo;&lt;/strong> in any API.&lt;/li>
&lt;li>&lt;strong>In OpenAI, the allowed list inside the MCP block and the identically named tool-choice type are different things.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Gemini does accept a per-tool allowlist&lt;/strong> on its remote MCP servers.&lt;/li>
&lt;li>&lt;strong>Reordering the catalogue repairs at most 23 % of the failures.&lt;/strong>&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The starting question had a trap in it, and the trap is the word. When someone asks how you prioritise an MCP server, they are assuming a dial exists somewhere. It does not, and I have spent the whole article showing the places where it is not.&lt;/p>
&lt;p>What there is instead is poorer and more manageable: you remove what competes, you leave your own in front, and you write the description better. Three things, none of them elegant, all three effective in that order.&lt;/p>
&lt;p>What does seem to me worth taking away is the shape of the problem. The tool catalogue a model sees is security configuration in the full sense, because it determines what the agent can do and which system it is going to talk to. And today it is managed like a list of connections: things get added, it is not versioned, it is not signed, and nobody detects a change. The ecosystem has already solved that problem once, for skills, with hashes and tied approval. For tools, not yet.&lt;/p>
&lt;p>In the meantime, the asymmetry has to be built by hand, server by server, and knowing that it rests on the &lt;code>_meta&lt;/code> of an object that a library can empty without saying anything.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a> — the article this one is a direct follow-up to, with the catalogue as cost and the ladder that gets corrected here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a> — the separation by key and team that credential-based filtering rests on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — what it costs in proxy CPU to serialise the catalogue on every request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing&lt;/a> — why touching the start of the prompt is paid for so dearly.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a> — the protected resource side and audience validation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a> — the identity of your own MCP servers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its deep observability&lt;/a> — the protocol and its primitives.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for ENS, 42001 and the AI Act&lt;/a> — the framework where change management over the catalogue fits.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Model Context Protocol, herramientas en la revisión &lt;code>2026-07-28&lt;/code>: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/tools">https://modelcontextprotocol.io/specification/2026-07-28/server/tools&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, &lt;code>server/discover&lt;/code>: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/discover">https://modelcontextprotocol.io/specification/2026-07-28/server/discover&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, paginación: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination">https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, caché de listados: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching">https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, esquema fuente de la revisión: &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2026-07-28/schema.ts">https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2026-07-28/schema.ts&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, extensión de habilidades: &lt;a href="https://modelcontextprotocol.io/extensions/skills/overview">https://modelcontextprotocol.io/extensions/skills/overview&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, grupo de interés sobre agrupación de primitivas: &lt;a href="https://modelcontextprotocol.io/community/interest-groups/primitive-grouping">https://modelcontextprotocol.io/community/interest-groups/primitive-grouping&lt;/a>.&lt;/li>
&lt;li>Anthropic, Claude Code y MCP (&lt;code>alwaysLoad&lt;/code>, búsqueda de herramientas): &lt;a href="https://code.claude.com/docs/en/mcp">https://code.claude.com/docs/en/mcp&lt;/a>.&lt;/li>
&lt;li>Anthropic, Claude Code, referencia de línea de comandos (&lt;code>--tools&lt;/code>): &lt;a href="https://code.claude.com/docs/en/cli-reference">https://code.claude.com/docs/en/cli-reference&lt;/a>.&lt;/li>
&lt;li>Anthropic, Claude Code, permisos (denegación por nombre pelado): &lt;a href="https://code.claude.com/docs/en/permissions">https://code.claude.com/docs/en/permissions&lt;/a>.&lt;/li>
&lt;li>Anthropic, conector MCP y conjuntos de herramientas: &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/mcp-connector">https://platform.claude.com/docs/en/agents-and-tools/mcp-connector&lt;/a>.&lt;/li>
&lt;li>Anthropic, cambios de herramientas a mitad de conversación: &lt;a href="https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages">https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages&lt;/a>.&lt;/li>
&lt;li>Anthropic, búsqueda de herramientas: &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool">https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool&lt;/a>.&lt;/li>
&lt;li>OpenAI, MCP y conectores (lista de herramientas permitidas): &lt;a href="https://developers.openai.com/api/docs/guides/tools-connectors-mcp">https://developers.openai.com/api/docs/guides/tools-connectors-mcp&lt;/a>.&lt;/li>
&lt;li>OpenAI, búsqueda de herramientas y caché: &lt;a href="https://developers.openai.com/api/docs/guides/tools-tool-search">https://developers.openai.com/api/docs/guides/tools-tool-search&lt;/a>.&lt;/li>
&lt;li>Google, llamada a funciones en la Interactions API: &lt;a href="https://ai.google.dev/gemini-api/docs/function-calling">https://ai.google.dev/gemini-api/docs/function-calling&lt;/a>.&lt;/li>
&lt;li>vLLM, llamada a herramientas y decodificación restringida: &lt;a href="https://docs.vllm.ai/en/latest/features/tool_calling.html">https://docs.vllm.ai/en/latest/features/tool_calling.html&lt;/a>.&lt;/li>
&lt;li>vLLM, propuesta de decodificación guiada por regiones y gramáticas de herramienta: &lt;a href="https://github.com/vllm-project/vllm/issues/39848">https://github.com/vllm-project/vllm/issues/39848&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código del gateway MCP: &lt;code>litellm/proxy/_experimental/mcp_server/server.py&lt;/code>, &lt;code>mcp_server_manager.py&lt;/code>, &lt;code>rest_endpoints.py&lt;/code>, &lt;code>tool_search.py&lt;/code>, &lt;code>litellm/experimental_mcp_client/tools.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>SDK de Python de MCP, modelo &lt;code>Tool&lt;/code> y serialización por alias: &lt;a href="https://github.com/modelcontextprotocol/python-sdk">https://github.com/modelcontextprotocol/python-sdk&lt;/a>.&lt;/li>
&lt;li>&lt;em>BiasBusters&lt;/em>, sesgo de selección entre herramientas equivalentes y peso de la descripción frente al nombre: &lt;a href="https://arxiv.org/html/2510.00307v2">https://arxiv.org/html/2510.00307v2&lt;/a>.&lt;/li>
&lt;li>&lt;em>Looking Is Not Picking&lt;/em>, atención frente a lectura final y techo de las intervenciones sobre el prompt: &lt;a href="https://arxiv.org/html/2606.16364v1">https://arxiv.org/html/2606.16364v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>A Single Rewrite Suffices&lt;/em>, reescritura de descripciones y generalización al conjunto recuperado: &lt;a href="https://arxiv.org/html/2606.30775">https://arxiv.org/html/2606.30775&lt;/a>.&lt;/li>
&lt;li>&lt;em>ToolFlood&lt;/em>, saturación adversaria de la capa de recuperación: &lt;a href="https://arxiv.org/html/2603.13950">https://arxiv.org/html/2603.13950&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Langfuse v4, day 2 (5 of 10): the worker's thirty-nine queues, and the three that decide whether you lose data</title><link>https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/</link><pubDate>Mon, 14 Sep 2026 08:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/</guid><description>&lt;blockquote>
&lt;p>Fifth article in a series of ten about operating Langfuse v4 in production. The previous one covered &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">migrating from version 3&lt;/a>. This one goes inside the worker that is already running. Verified against the Langfuse 4.35.0 source, commit &lt;code>2ee1908&lt;/code> of 14 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Thirty-nine queues and thirty-two switches.&lt;/strong> The catalogue is in &lt;code>packages/shared/src/server/queues.ts:406&lt;/code>. The &lt;code>QUEUE_CONSUMER_*_IS_ENABLED&lt;/code> variables are read once at boot, so turning a queue off is a restart, not a hot change.&lt;/p>
&lt;p>&lt;strong>Sharding spreads nothing without Redis Cluster.&lt;/strong> The producer only applies the hash if &lt;code>REDIS_CLUSTER_ENABLED === &amp;quot;true&amp;quot;&lt;/code>, and that variable ships &lt;code>&amp;quot;false&amp;quot;&lt;/code>. Raising &lt;code>LANGFUSE_INGESTION_QUEUE_SHARD_COUNT&lt;/code> on an installation with single-node Redis starts N workers and leaves N−1 with no work.&lt;/p>
&lt;p>&lt;strong>The dead-letter retrier ships disabled.&lt;/strong> &lt;code>QUEUE_CONSUMER_DEAD_LETTER_RETRY_QUEUE_IS_ENABLED&lt;/code> is the only one of the thirty-two whose default is &lt;code>&amp;quot;false&amp;quot;&lt;/code>.&lt;/p>
&lt;p>&lt;strong>And even switched on, it does not cover ingestion.&lt;/strong> Its list holds five queues and none is &lt;code>ingestion-queue&lt;/code>. An event that exhausts its six attempts sits in the failed set.&lt;/p>
&lt;p>&lt;strong>The ClickHouse writer drops rows.&lt;/strong> Three attempts with a flat hundred-millisecond delay and then out. The code comment is explicit: &lt;code>// TODO - Add to a dead letter queue in Redis rather than dropping&lt;/code>. The metric to watch is called &lt;code>langfuse.queue.clickhouse_writer.rows_dropped&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Turn a queue&amp;rsquo;s consumer off and you stop seeing its depth.&lt;/strong> The metrics emitter only polls queues with a worker registered in that container. The queue keeps growing and the dashboard goes flat.&lt;/p>
&lt;p>&lt;strong>Project deletion runs one job every ten minutes.&lt;/strong> By limiter, deliberately, because each one scours ClickHouse.&lt;/p>
&lt;p>&lt;strong>There are two optional health probes and one of them can cause the very restart loop it is meant to fix&lt;/strong> if configured with less than sixty seconds of initial delay.&lt;/p>
&lt;h2 id="you-are-here-observe-day-2">You are here: OBSERVE, day 2&lt;/h2>
&lt;p>The previous article left the installation migrated. This is the map of the process that does the work, and it comes before the capacity article because the queues are where saturation shows up before ClickHouse notices.&lt;/p>
&lt;h2 id="the-analogy-the-sorting-hub">The analogy: the sorting hub&lt;/h2>
&lt;p>A postal sorting hub has separate belts for parcels, registered mail, returns and internal pouches. Each belt has its speed and its staff, and some are throttled on purpose because the destination cannot take more.&lt;/p>
&lt;p>The job of whoever runs it is not to watch the fastest belt, it is to know which one jams first, which can stop for an afternoon without anyone noticing and which cannot stop for ten minutes. And above all, where the bin for items nobody could sort is, because every hub has one and in many of them nobody looks at it.&lt;/p>
&lt;p>The Langfuse worker has thirty-nine belts. This article is the plan, with the speeds and the location of the bin.&lt;/p>
&lt;h2 id="the-map-of-the-thirty-nine">The map of the thirty-nine&lt;/h2>
&lt;p>Grouped by what they do, with what stops happening if the group stops:&lt;/p>
&lt;p>&lt;strong>Ingestion (4).&lt;/strong> &lt;code>ingestion-queue&lt;/code>, &lt;code>secondary-ingestion-queue&lt;/code>, &lt;code>otel-ingestion-queue&lt;/code>, &lt;code>secondary-otel-ingestion-queue&lt;/code>. If they stop, the web keeps accepting events and uploading them to object storage, and keeps enqueueing. Nothing reaches ClickHouse. The SDK sees no error at all. This is the group that cannot stop.&lt;/p>
&lt;p>&lt;strong>Propagation (1).&lt;/strong> &lt;code>event-propagation-queue&lt;/code>, the dual-write one from the previous article, with a global concurrency of one across the cluster.&lt;/p>
&lt;p>&lt;strong>Evaluation (8).&lt;/strong> &lt;code>trace-upsert&lt;/code>, &lt;code>dataset-run-item-upsert-queue&lt;/code>, &lt;code>create-eval-queue&lt;/code>, &lt;code>evaluation-execution-queue&lt;/code> and its secondary, &lt;code>llm-as-a-judge-execution-queue&lt;/code>, &lt;code>code-eval-execution-queue&lt;/code>, &lt;code>experiment-create-queue&lt;/code>. If they stop, evaluations are neither created nor executed. Traces keep arriving. This is the group that can wait.&lt;/p>
&lt;p>&lt;strong>Deletion and retention (7).&lt;/strong> &lt;code>trace-delete&lt;/code>, &lt;code>score-delete&lt;/code>, &lt;code>dataset-delete-queue&lt;/code>, &lt;code>project-delete&lt;/code>, &lt;code>data-retention-queue&lt;/code> and its processing queue, &lt;code>batch-action-queue&lt;/code>. If they stop, neither UI-requested deletions nor per-project retention run. That is not an operational annoyance, it is a breach if you have a retention commitment in writing.&lt;/p>
&lt;p>&lt;strong>Exports (3).&lt;/strong> &lt;code>batch-export-queue&lt;/code>, &lt;code>core-data-s3-export-queue&lt;/code>, &lt;code>metering-data-postgres-export-queue&lt;/code>. Downloads a user asked for stay pending.&lt;/p>
&lt;p>&lt;strong>Outbound integrations (8).&lt;/strong> The PostHog, Mixpanel and blob storage pairs, plus &lt;code>webhook-queue&lt;/code>, &lt;code>entity-change-queue&lt;/code> and &lt;code>notification-queue&lt;/code>. In each pair, the bare queue is the scheduler that fans out per project and the processing one does the work.&lt;/p>
&lt;p>&lt;strong>Cloud billing (4).&lt;/strong> They only register with a Stripe key. On a self-hosted installation they never start.&lt;/p>
&lt;p>&lt;strong>Maintenance (4).&lt;/strong> &lt;code>dead-letter-retry-queue&lt;/code>, &lt;code>monitor-queue&lt;/code>, &lt;code>in-app-agent-run-queue&lt;/code> and &lt;code>v4-legacy-api-usage-queue&lt;/code>, which ingests nothing: it is a cron that scans ClickHouse&amp;rsquo;s &lt;code>system.query_log&lt;/code> to find which projects still use the old API.&lt;/p>
&lt;p>The switches number thirty-two, all &lt;code>QUEUE_CONSUMER_*_IS_ENABLED&lt;/code> and all &lt;code>&amp;quot;true&amp;quot;&lt;/code> except the dead-letter retrier. Four queues have no switch of their own: the model-as-judge one rides inside the evaluation switch, the two Cloud data export queues have their own variable, and the processing queues share a switch with their scheduler.&lt;/p>
&lt;p>The mechanism is a top-level &lt;code>if&lt;/code> around each worker registration. A false switch means the worker is never instantiated, not that the queue stops receiving work: &lt;strong>producers keep enqueueing regardless&lt;/strong>. Turning a queue off is not consuming it, not not filling it.&lt;/p>
&lt;h2 id="the-sharding-that-does-not-shard">The sharding that does not shard&lt;/h2>
&lt;p>Nine queues support sharding: the four ingestion ones, three evaluation ones, model-as-judge and &lt;code>trace-upsert&lt;/code>. Each has its own shard-count variable, and &lt;strong>they all default to 1&lt;/strong>.&lt;/p>
&lt;p>Raising them looks like the obvious first move to scale ingestion. It is not, because of this (&lt;code>packages/shared/src/server/redis/ingestionQueue.ts:48&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">shardIndex&lt;/span> &lt;span class="o">=&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">IngestionQueue&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getShardIndexFromShardName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shardName&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">??&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">REDIS_CLUSTER_ENABLED&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s2">&amp;#34;true&amp;#34;&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">shardingKey&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">?&lt;/span> &lt;span class="nx">getShardIndex&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shardingKey&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">LANGFUSE_INGESTION_QUEUE_SHARD_COUNT&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>: 0&lt;/code> at the end is the whole story. The producer only computes a shard if Redis is in cluster mode, and &lt;code>REDIS_CLUSTER_ENABLED&lt;/code> ships &lt;code>&amp;quot;false&amp;quot;&lt;/code>. On an installation with single-node Redis, which is most self-hosted installations, raising the shard count to four starts four workers, three of which stare at empty queues, while all traffic keeps coming through &lt;code>ingestion-queue&lt;/code>.&lt;/p>
&lt;p>The sharding key, when it does apply, is &lt;code>projectId-eventBodyId&lt;/code> run through SHA-256, taking the first eight hex characters and a modulo. Shard names are &lt;code>ingestion-queue&lt;/code> for zero and &lt;code>ingestion-queue-N&lt;/code> for the rest.&lt;/p>
&lt;p>Without Redis Cluster, the lever that does work is concurrency, which is a different thing.&lt;/p>
&lt;h2 id="concurrencies-and-the-ones-set-to-one-on-purpose">Concurrencies, and the ones set to one on purpose&lt;/h2>
&lt;p>The defaults, worth knowing because several sit at one for good reasons:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Queue&lt;/th>
&lt;th>Variable&lt;/th>
&lt;th>Default&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>ingestion-queue&lt;/code> (per shard)&lt;/td>
&lt;td>&lt;code>LANGFUSE_INGESTION_QUEUE_PROCESSING_CONCURRENCY&lt;/code>&lt;/td>
&lt;td>&lt;strong>20&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>secondary-ingestion-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>LANGFUSE_INGESTION_SECONDARY_QUEUE_PROCESSING_CONCURRENCY&lt;/code>&lt;/td>
&lt;td>5&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>otel-ingestion-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>LANGFUSE_OTEL_INGESTION_QUEUE_PROCESSING_CONCURRENCY&lt;/code>&lt;/td>
&lt;td>5&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>trace-upsert&lt;/code>&lt;/td>
&lt;td>&lt;code>LANGFUSE_TRACE_UPSERT_WORKER_CONCURRENCY&lt;/code>&lt;/td>
&lt;td>&lt;strong>25&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>monitor-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>LANGFUSE_MONITOR_QUEUE_PROCESSING_CONCURRENCY&lt;/code>&lt;/td>
&lt;td>10&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>evaluation and model-as-judge&lt;/td>
&lt;td>several&lt;/td>
&lt;td>5&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>create-eval-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>LANGFUSE_EVAL_CREATOR_WORKER_CONCURRENCY&lt;/code>&lt;/td>
&lt;td>2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>trace-delete&lt;/code>, &lt;code>score-delete&lt;/code>, &lt;code>dataset-delete-queue&lt;/code>, &lt;code>project-delete&lt;/code>&lt;/td>
&lt;td>several&lt;/td>
&lt;td>&lt;strong>1&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>event-propagation-queue&lt;/code>&lt;/td>
&lt;td>fixed in code&lt;/td>
&lt;td>&lt;strong>1 cluster-wide&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Deletions sit at one and carry a rate limiter on top. The project one is the most aggressive (&lt;code>worker/src/app.ts:234&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">limiter&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="c1">// Process at most `max` delete jobs per LANGFUSE_CLICKHOUSE_PROJECT_DELETION_CONCURRENCY_DURATION_MS (default 10 min)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">max&lt;/span>: &lt;span class="kt">env.LANGFUSE_PROJECT_DELETE_CONCURRENCY&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>One project deletion every ten minutes. If a customer asks you to delete forty projects, that is six hours and forty minutes on the clock, and rightly so, because each of those deletions is a broom sweeping through ClickHouse.&lt;/p>
&lt;p>The ingestion queues, by contrast, have &lt;strong>no limiter at all&lt;/strong>. There the brake is concurrency and the ClickHouse writer.&lt;/p>
&lt;p>One BullMQ detail that recurs across queues and explains a good number of other people&amp;rsquo;s stalls (&lt;code>worker/src/app.ts:261&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// The default lockDuration is 30s and the lockRenewTime 1/2 of that.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// We set it to 60s to reduce the number of lock renewals and also be less sensitive to high CPU wait times.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">lockDuration&lt;/span>: &lt;span class="kt">60000&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 60 seconds
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">stalledInterval&lt;/span>: &lt;span class="kt">120000&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 120 seconds
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">maxStalledCount&lt;/span>: &lt;span class="kt">3&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="an-events-path-from-api-to-clickhouse">An event&amp;rsquo;s path, from API to ClickHouse&lt;/h2>
&lt;p>It is worth following it end to end once, because there are three places it can be lost.&lt;/p>
&lt;p>The web groups the batch&amp;rsquo;s events by body id, uploads one JSON file per group to object storage and &lt;strong>aborts if that upload fails&lt;/strong>: no blob, no enqueue. Then it enqueues one job per body id on &lt;code>ingestion-queue&lt;/code>.&lt;/p>
&lt;p>The worker picks up the job, optionally checks a recently-processed cache in Redis (off by default, five-minute lifetime), decides whether to redirect the project to the secondary queue, and downloads the file from storage. There is a comment here worth a capacity chart on its own:&lt;/p>
&lt;blockquote>
&lt;p>Process files in batches. If a user has 5k events, this will likely take 100 seconds.&lt;/p>
&lt;/blockquote>
&lt;p>Then it merges and writes. And writing is not writing: the service does not talk to ClickHouse, it puts the row in a buffer whose defaults are &lt;strong>1,000 rows, 1,000 milliseconds and 3 attempts&lt;/strong>.&lt;/p>
&lt;p>That buffer is the third place things get lost, and the most serious one in the system (&lt;code>worker/src/services/ClickhouseWriter/index.ts:535&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// Re-add the records to the queue with incremented attempts
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">let&lt;/span> &lt;span class="nx">droppedCount&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">queueItems&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="nx">item&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&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="p">(&lt;/span>&lt;span class="nx">item&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">attempts&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">maxAttempts&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">entityQueue&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="p">...&lt;/span>&lt;span class="nx">item&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">attempts&lt;/span>: &lt;span class="kt">item.attempts&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// TODO - Add to a dead letter queue in Redis rather than dropping
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">recordIncrement&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;langfuse.queue.clickhouse_writer.error&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">droppedCount&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="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>Once the three attempts are spent, the row is dropped. And no queue retry saves it, because the BullMQ job already completed successfully: as far as the queue is concerned, that event was processed. The internal retries are not properly exponential either: the delay is flat at a hundred milliseconds, so all three attempts happen within three hundred milliseconds and a ClickHouse that is unwell for half a second takes the batch with it.&lt;/p>
&lt;p>The runbook consequence is direct. &lt;strong>&lt;code>langfuse.queue.clickhouse_writer.rows_dropped&lt;/code> has to be on a dashboard and it has to have an alert at zero.&lt;/strong> Any non-zero value is silent data loss.&lt;/p>
&lt;h2 id="retries-and-the-bin-nobody-looks-at">Retries, and the bin nobody looks at&lt;/h2>
&lt;p>Each queue defines its own policy. The ones that matter:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Queue&lt;/th>
&lt;th>Attempts&lt;/th>
&lt;th>Backoff&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>ingestion-queue&lt;/code>&lt;/td>
&lt;td>6&lt;/td>
&lt;td>exponential, 5 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>secondary-ingestion-queue&lt;/code>&lt;/td>
&lt;td>5&lt;/td>
&lt;td>exponential, 5 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>evaluation-execution-queue&lt;/code>&lt;/td>
&lt;td>10&lt;/td>
&lt;td>exponential, 1 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>code-eval-execution-queue&lt;/code>&lt;/td>
&lt;td>3&lt;/td>
&lt;td>exponential, 30 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>trace-upsert&lt;/code>&lt;/td>
&lt;td>2&lt;/td>
&lt;td>exponential, 5 s, with a 30 s initial delay&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>trace-delete&lt;/code>, &lt;code>score-delete&lt;/code>&lt;/td>
&lt;td>2&lt;/td>
&lt;td>exponential, 30 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>monitor-queue&lt;/code>, &lt;code>in-app-agent-run-queue&lt;/code>&lt;/td>
&lt;td>&lt;strong>1&lt;/strong>&lt;/td>
&lt;td>no backoff, deliberately&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The two single-attempt ones are a design decision, and the comment explains it well: &lt;em>&amp;ldquo;Postgres owns correctness (claim CAS + reconcile-on-read); BullMQ is delivery-only, so never redeliver on its own.&amp;rdquo;&lt;/em>&lt;/p>
&lt;p>And now the bin. &lt;strong>There is no dedicated dead letter queue.&lt;/strong> What exists is BullMQ&amp;rsquo;s native &lt;code>failed&lt;/code> set per queue, with its own retention, plus a queue that retries from there. That queue has two problems for whoever operates it:&lt;/p>
&lt;p>First, &lt;strong>it ships disabled&lt;/strong>. It is the only one of the thirty-two defaulting to &lt;code>&amp;quot;false&amp;quot;&lt;/code>.&lt;/p>
&lt;p>Second, even switched on, its scope is hand-written and has five entries (&lt;code>worker/src/services/dlq/dlqRetryService.ts:9&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">private&lt;/span> &lt;span class="kr">static&lt;/span> &lt;span class="nx">retryQueues&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="nx">QueueName&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ProjectDelete&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">QueueName&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">TraceDelete&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">QueueName&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ScoreDelete&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">QueueName&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">BatchActionQueue&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">QueueName&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">DataRetentionProcessingQueue&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span> &lt;span class="kr">as&lt;/span> &lt;span class="kr">const&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>ingestion-queue&lt;/code> is not there. Ingestion events that exhaust their six attempts sit in the failed set, up to a hundred thousand of them, and nobody ever retries them. To get them out there is an admin route, &lt;code>POST /api/admin/bullmq&lt;/code> with action &lt;code>retry&lt;/code>, which the file itself describes as the one the managed service uses for exactly this.&lt;/p>
&lt;h2 id="the-metrics-with-their-exact-names">The metrics, with their exact names&lt;/h2>
&lt;p>Each queue&amp;rsquo;s metric name is built at runtime from the queue name, turning dashes into underscores and stripping the &lt;code>_queue&lt;/code> suffix. So &lt;code>ingestion-queue&lt;/code> produces &lt;code>langfuse.queue.ingestion&lt;/code>, and &lt;code>secondary-ingestion-queue&lt;/code> produces &lt;code>langfuse.queue.secondary_ingestion&lt;/code>. Sharded queues always add a &lt;code>shard&lt;/code> tag.&lt;/p>
&lt;p>What comes out of that:&lt;/p>
&lt;ul>
&lt;li>&lt;code>langfuse.queue.&amp;lt;queue&amp;gt;.rate&lt;/code> with &lt;code>type&lt;/code> in &lt;code>request&lt;/code>, &lt;code>completed&lt;/code>, &lt;code>failed&lt;/code>, &lt;code>error&lt;/code> and &lt;code>stalled&lt;/code>.&lt;/li>
&lt;li>&lt;code>langfuse.queue.&amp;lt;queue&amp;gt;.time_distribution&lt;/code> with &lt;code>type&lt;/code> in &lt;code>wait&lt;/code> and &lt;code>processing&lt;/code>.&lt;/li>
&lt;li>&lt;code>langfuse.queue.&amp;lt;queue&amp;gt;.depth&lt;/code> with &lt;code>type&lt;/code> in &lt;code>waiting&lt;/code>, &lt;code>failed&lt;/code> and &lt;code>active&lt;/code>, plus an aggregate series with &lt;code>shard: &amp;quot;all&amp;quot;&lt;/code>.&lt;/li>
&lt;li>&lt;code>langfuse.queue.&amp;lt;queue&amp;gt;.dlq_oldest_age&lt;/code>, the age of the oldest failed job, which emits zero when the set empties so the dashboard resets.&lt;/li>
&lt;li>From the writer: &lt;code>langfuse.queue.clickhouse_writer.rows_dropped&lt;/code>, &lt;code>.error&lt;/code>, &lt;code>.processing_time&lt;/code> and &lt;code>ingestion_clickhouse_insert_queue_length&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>Two things to know about those metrics. The &lt;code>waiting&lt;/code> depth &lt;strong>includes paused jobs&lt;/strong>, to match BullMQ&amp;rsquo;s counter. And the big trap (&lt;code>worker/src/features/queue-metrics-runner/index.ts:70&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// Only poll queues that have registered workers. This avoids calling
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// getInstance() on queues this worker doesn&amp;#39;t consume, which would
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// create unnecessary Redis connections and can trigger side effects
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Only queues that container consumes emit metrics. If you split roles and turn a queue&amp;rsquo;s consumer off across every container, that queue &lt;strong>stops appearing on dashboards&lt;/strong> while it keeps growing in Redis. It is the perfect way not to find out.&lt;/p>
&lt;p>The metric that saturates first is still the same one: &lt;code>langfuse.queue.ingestion.depth&lt;/code> with &lt;code>type: &amp;quot;waiting&amp;quot;&lt;/code>. The emission interval defaults to one second.&lt;/p>
&lt;h2 id="the-two-probes-and-the-one-that-can-restart-you-in-a-loop">The two probes, and the one that can restart you in a loop&lt;/h2>
&lt;p>The worker exposes &lt;code>/api/health&lt;/code> and &lt;code>/api/ready&lt;/code>, identical but for one thing: the second returns 500 after SIGTERM, which is how draining works. Both always check Postgres with a &lt;code>SELECT 1&lt;/code> and Redis with a ping capped at &lt;strong>two seconds, hardcoded&lt;/strong>.&lt;/p>
&lt;p>Then there are two optional checks enabled by query parameter:&lt;/p>
&lt;p>&lt;strong>&lt;code>?failIfEventPropagationStuck=true&lt;/code>&lt;/strong> looks at the propagator&amp;rsquo;s heartbeat and returns 503 if it has not been refreshed for more than 35 minutes. A missing heartbeat does not count as stuck, deliberately, so a freshly booted container is not restarted in a loop. And the condition that must be respected:&lt;/p>
&lt;blockquote>
&lt;p>Probes using this flag MUST set &lt;code>initialDelaySeconds &amp;gt;= 60s&lt;/code> (one cron cycle) [&amp;hellip;] A shorter delay can crash-loop the very restart this check triggers.&lt;/p>
&lt;/blockquote>
&lt;p>&lt;strong>&lt;code>?failIfQueueConsumptionStuck=true&lt;/code>&lt;/strong> is an in-memory signal of that container, with no Redis involved: it is marked on the &lt;code>active&lt;/code> and &lt;code>completed&lt;/code> events of any worker. It returns 503 if that container has gone 60 minutes without picking up or finishing a single job. It exists for a very specific failure mode:&lt;/p>
&lt;blockquote>
&lt;p>After Redis lock loss BullMQ workers can wedge permanently: the process stays alive and connectivity checks pass, but no queue picks up jobs ever again.&lt;/p>
&lt;/blockquote>
&lt;p>The one-hour threshold leans on the default crons keeping a healthy worker busy at least once an hour. In multi-replica deployments each scheduler tick lands on a single replica, so the comment itself warns about raising the threshold if a replica can legitimately sit idle.&lt;/p>
&lt;p>Draining, by the way, has no cap. The order is close the HTTP server, stop the periodic runners, close the BullMQ workers and &lt;strong>then&lt;/strong> flush the ClickHouse writer, which on shutdown empties the whole queue rather than one batch. The only thing bounding that drain is the Kubernetes grace period, so it had better be generous.&lt;/p>
&lt;h2 id="the-crons">The crons&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Queue&lt;/th>
&lt;th>Pattern&lt;/th>
&lt;th>What it does&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>event-propagation-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>* * * * *&lt;/code>&lt;/td>
&lt;td>propagates one staging partition&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>dead-letter-retry-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>0 */10 * * * *&lt;/code>&lt;/td>
&lt;td>retries failed jobs from five queues&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>v4-legacy-api-usage-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>*/15 * * * *&lt;/code>&lt;/td>
&lt;td>scans &lt;code>system.query_log&lt;/code> for old-API usage&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>blobstorage-integration-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>*/20 * * * *&lt;/code>&lt;/td>
&lt;td>fans exports out per project&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>cloud-usage-metering-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>5 * * * *&lt;/code>&lt;/td>
&lt;td>Cloud billing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>posthog-integration-queue&lt;/code>, &lt;code>mixpanel-integration-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>30 * * * *&lt;/code>&lt;/td>
&lt;td>hourly fan-out&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>metering-data-postgres-export-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>30 2 * * *&lt;/code>&lt;/td>
&lt;td>daily export&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>core-data-s3-export-queue&lt;/code>, &lt;code>data-retention-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>15 3 * * *&lt;/code>&lt;/td>
&lt;td>daily export and retention&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>And a warning the scheduler file itself writes in capitals: cron registration is fire-and-forget from the queue constructors, and a failure is only logged. A transient Redis failure at boot can leave a cron unscheduled until the next restart, &lt;strong>without the container ever being marked unhealthy&lt;/strong>.&lt;/p>
&lt;h2 id="splitting-roles-which-is-what-all-this-is-for">Splitting roles, which is what all this is for&lt;/h2>
&lt;p>With thirty-two switches, what you can build is one worker per family. The split that makes sense on an installation with agentic load:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Pool 1: ingestion. This is the one that scales with traffic.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_INGESTION_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_OTEL_INGESTION_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_EVENT_PROPAGATION_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="c1"># note: global concurrency 1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_INGESTION_QUEUE_PROCESSING_CONCURRENCY&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">20&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># everything else false&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"># Pool 2: evaluations. Scales with the number of evaluators, not with traffic.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_EVAL_EXECUTION_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_CREATE_EVAL_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_TRACE_UPSERT_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_EXPERIMENT_CREATE_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Pool 3: maintenance. One replica is enough.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_TRACE_DELETE_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_PROJECT_DELETE_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_DATA_RETENTION_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_BATCH_EXPORT_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_DEAD_LETTER_RETRY_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="c1"># switch it on&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two warnings about this split. Each worker registration opens &lt;strong>its own Redis connection&lt;/strong>, so the connection count grows with queues times shards times replicas, and that is checked against Redis &lt;code>maxclients&lt;/code> before multiplying replicas. And the moment you split roles you lose the metrics of the queues that container does not consume, so &lt;strong>some&lt;/strong> replica needs to consume every queue or you go blind on that one.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>There is an alert on &lt;code>langfuse.queue.clickhouse_writer.rows_dropped&lt;/code>, threshold zero.&lt;/li>
&lt;li>There is an alert on the depth of &lt;code>langfuse.queue.ingestion&lt;/code> with &lt;code>type: &amp;quot;waiting&amp;quot;&lt;/code>.&lt;/li>
&lt;li>There is an alert on &lt;code>dlq_oldest_age&lt;/code> for the queues that matter, and somebody knows how to use &lt;code>POST /api/admin/bullmq&lt;/code>.&lt;/li>
&lt;li>The dead-letter retrier is on in some replica, knowing it does not cover ingestion.&lt;/li>
&lt;li>Nobody raised the shard count without Redis Cluster.&lt;/li>
&lt;li>The optional health probes are configured with &lt;code>initialDelaySeconds&lt;/code> of at least 60 seconds.&lt;/li>
&lt;li>The Kubernetes termination grace period allows the ClickHouse writer to flush.&lt;/li>
&lt;li>Every queue is consumed by at least one replica, so its metrics are not lost.&lt;/li>
&lt;li>Redis &lt;code>maxclients&lt;/code> copes with queues times shards times replicas.&lt;/li>
&lt;/ul>
&lt;h2 id="traps">Traps&lt;/h2>
&lt;p>&lt;strong>Sharding without Redis Cluster does not spread load.&lt;/strong> It starts the workers and leaves them idle.&lt;/p>
&lt;p>&lt;strong>The dead-letter retrier ships disabled and does not cover ingestion.&lt;/strong> Two independent facts, both uncomfortable.&lt;/p>
&lt;p>&lt;strong>The ClickHouse writer drops rows after three attempts.&lt;/strong> With a TODO in the code admitting it and three hundred milliseconds of total headroom.&lt;/p>
&lt;p>&lt;strong>Turning a consumer off also turns its metrics off.&lt;/strong> The queue keeps growing, the dashboard stays flat.&lt;/p>
&lt;p>&lt;strong>A cron can end up unscheduled while the container stays healthy.&lt;/strong> A transient Redis failure at boot, and it is gone until the next restart.&lt;/p>
&lt;p>&lt;strong>The PostHog and blob storage integrations disable themselves&lt;/strong> when an error is classified as a customer-config fault, and if the notification also fails they stay off and silent.&lt;/p>
&lt;p>&lt;strong>A misconfigured propagation probe restarts in a loop.&lt;/strong> Less than sixty seconds of initial delay and you get the cycle.&lt;/p>
&lt;p>&lt;strong>Draining has no cap of its own.&lt;/strong> The Kubernetes grace period bounds it and nothing else.&lt;/p>
&lt;h2 id="the-series-the-ten-articles">The series: the ten articles&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">What goes into a trace&lt;/a>: version 4&amp;rsquo;s data model, limits, precedences, scores, masking and indexes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">Putting LangGraph in front&lt;/a>: instrumenting an agentic platform and the measured cost of one turn.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/">Two well-known agents instrumented&lt;/a>: Open Deep Research and GPT Researcher, with the measured figures of a real request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">Migrating from version 3 to 4 with no window&lt;/a>: the three write-mode steps, the resumable background migrations and where the rollback point of no return sits.&lt;/li>
&lt;li>The worker queues (this article): the map of all thirty-nine, which pool to dedicate to each group, the per-queue switches, sharding and concurrency.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/clickhouse-capacity-langfuse-v4-bytes-per-observation/">Real ClickHouse capacity and cost&lt;/a>: how to measure bytes per observation with the system tables, the difference between the full table and the listings table, and the merge cost of full-text indexes.&lt;/li>
&lt;li>Retention, deletion and data protection: why a deletion does not free disk, the mask cleaner that ships disabled, the pending deletion queue and the S3 lifecycle that has to be implemented by hand.&lt;/li>
&lt;li>Backup and cross recovery: restore ordering across Postgres, ClickHouse and object storage, what each mismatch breaks, and how far event replay goes.&lt;/li>
&lt;li>Saturation runbook: what to alert on from the queue metrics, the stuck probes, draining through the readiness endpoint and the dead letter queue.&lt;/li>
&lt;li>Getting the data out: the blob storage integration to Parquet, batch exports and the metrics API, to build the data lake.&lt;/li>
&lt;/ol>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">Migrating from version 3 to 4 with no window&lt;/a>: this article&amp;rsquo;s propagation queue is that one&amp;rsquo;s dual-write.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">Langfuse v4: what really goes into a trace&lt;/a>: what the ClickHouse writer ends up writing.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">Putting LangGraph in front&lt;/a>: where the volume that fills these queues comes from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a>: the six-service architecture and the performance levers.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Langfuse 4.35.0 source, commit &lt;code>2ee1908&lt;/code> of 14 September 2026: &lt;code>packages/shared/src/server/queues.ts&lt;/code>, &lt;code>packages/shared/src/server/redis/&lt;/code>, &lt;code>worker/src/env.ts&lt;/code>, &lt;code>worker/src/app.ts&lt;/code>, &lt;code>worker/src/queues/&lt;/code>, &lt;code>worker/src/services/ClickhouseWriter/&lt;/code>, &lt;code>worker/src/services/dlq/&lt;/code>, &lt;code>worker/src/features/health/&lt;/code>, &lt;code>worker/src/features/queue-metrics-runner/&lt;/code>, &lt;code>worker/src/utils/shutdown.ts&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/self-hosting">Langfuse self-hosting documentation&lt;/a>, checked on 14 September 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.bullmq.io/guide/going-to-production">BullMQ: going to production&lt;/a>, checked on 14 September 2026.&lt;/li>
&lt;/ul></description></item><item><title>Langfuse v4, day 2 (4 of 10): migrating from 3 to 4 with no window, and where the point of no return sits</title><link>https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/</link><pubDate>Mon, 14 Sep 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/</guid><description>&lt;blockquote>
&lt;p>Fourth article in a series of ten about operating Langfuse v4 in production. The previous three looked at the client side: &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what goes into a trace&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">what an agent turn costs&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/">two well-known agents instrumented&lt;/a>. This is the first one on the server side. Verified against the Langfuse 4.35.0 source, commit &lt;code>2ee1908&lt;/code> of 14 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>The default write mode is the destination, not the origin.&lt;/strong> &lt;code>LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/code> ships as &lt;code>events_only&lt;/code> (&lt;code>packages/shared/src/env.ts:337&lt;/code>). That is right for a new installation and wrong for a migration: in that mode the &lt;code>traces&lt;/code> and &lt;code>observations&lt;/code> tables stop being written.&lt;/p>
&lt;p>&lt;strong>The worker refuses to start on three combinations.&lt;/strong> &lt;code>legacy&lt;/code> with direct OTel writes, &lt;code>events_only&lt;/code> without the preview opt-in, and &lt;code>events_only&lt;/code> with OTel behaviour set to dual. The code comment names them: &lt;em>combinations that would silently lose data&lt;/em> (&lt;code>worker/src/env.ts:722&lt;/code>).&lt;/p>
&lt;p>&lt;strong>In &lt;code>events_only&lt;/code> twenty-four REST routes return 404.&lt;/strong> Traces, observations, sessions, v1 and v2 scores, metrics, spans, generations and the dataset-run ones. Not a bug: a deliberate short circuit so stale data from tables nobody writes is never served.&lt;/p>
&lt;p>&lt;strong>History is backfilled by a chain of five background migrations&lt;/strong>, not by the ClickHouse migrations. Not one of the ten new ClickHouse migrations copies a single old row.&lt;/p>
&lt;p>&lt;strong>A failed background migration does not block the next one.&lt;/strong> The manager has no dependency model and skips them. The chain protects itself with predecessor guards that &lt;strong>the first two steps do not declare&lt;/strong>, so a failure of step 1 does not stop step 2 from starting.&lt;/p>
&lt;p>&lt;strong>The old API&amp;rsquo;s ten-minute delay has an exact explanation.&lt;/strong> Three-minute partitions in the staging table, a lock four minutes after creation, and a per-minute cron that only touches partitions older than ten minutes, with a global concurrency of one.&lt;/p>
&lt;p>&lt;strong>That staging table has a forty-eight-hour TTL.&lt;/strong> If the propagator stops over a long weekend, those partitions disappear and the hole in &lt;code>events_full&lt;/code> is permanent. No code detects it and none repairs it.&lt;/p>
&lt;p>&lt;strong>The point of no return is in Postgres, not in ClickHouse.&lt;/strong> A Prisma migration runs &lt;code>DROP TABLE&lt;/code> on &lt;code>traces&lt;/code>, &lt;code>observations&lt;/code> and &lt;code>scores&lt;/code>, and Prisma Migrate has no down migrations.&lt;/p>
&lt;h2 id="you-are-here-observe-day-2">You are here: OBSERVE, day 2&lt;/h2>
&lt;p>The first three articles measured what an agent produces. This one starts on operating the backend that receives it, and it comes before the capacity article because you cannot size what has not been migrated yet.&lt;/p>
&lt;h2 id="the-analogy-changing-the-track-gauge-without-closing-the-line">The analogy: changing the track gauge without closing the line&lt;/h2>
&lt;p>Changing the gauge of a line in service is not done in one go. The third rail is laid, rolling stock of both gauges runs for a while, and only when every train is of the new gauge is the old rail lifted.&lt;/p>
&lt;p>The Langfuse migration has exactly that shape. &lt;code>dual&lt;/code> mode is the third rail: the old tables and the new ones are written at the same time. And as with the track, the danger is not the day of the change, it is at both ends. Laying the new rail and lifting the old one on the same day strands trains. And once the old rail is up, going back is not a matter of will, it is a matter of the material no longer being there.&lt;/p>
&lt;h2 id="the-three-rung-ladder">The three-rung ladder&lt;/h2>
&lt;p>Everything turns on one variable with three values, defined three times over in the three packages that have to agree, with the same enum and the same default (&lt;code>packages/shared/src/env.ts:337&lt;/code>, &lt;code>worker/src/env.ts:586&lt;/code>, &lt;code>web/src/env.mjs:610&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="nx">LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/span>: &lt;span class="kt">z&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="kr">enum&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s2">&amp;#34;legacy&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;dual&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;events_only&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="k">default&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;events_only&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The two helpers that read it say what each rung does better than any explanation (&lt;code>worker/src/env.ts:705&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">export&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">v4WritesToEventsTable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">envValue&lt;/span>: &lt;span class="kt">ParsedEnv&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kr">boolean&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">envValue&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/span> &lt;span class="o">!==&lt;/span> &lt;span class="s2">&amp;#34;legacy&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">export&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">v4WritesToLegacyTables&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">envValue&lt;/span>: &lt;span class="kt">ParsedEnv&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kr">boolean&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">envValue&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/span> &lt;span class="o">!==&lt;/span> &lt;span class="s2">&amp;#34;events_only&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What gets written in each mode:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Table&lt;/th>
&lt;th>&lt;code>legacy&lt;/code>&lt;/th>
&lt;th>&lt;code>dual&lt;/code>&lt;/th>
&lt;th>&lt;code>events_only&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>traces&lt;/code>, &lt;code>observations&lt;/code>&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>&lt;strong>no&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>scores&lt;/code>&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>dataset_run_items_rmt&lt;/code>&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>&lt;strong>no&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>observations_batch_staging&lt;/code>&lt;/td>
&lt;td>no&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>no&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>events_full&lt;/code>&lt;/td>
&lt;td>&lt;strong>no&lt;/strong>&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>events_core&lt;/code>&lt;/td>
&lt;td>no&lt;/td>
&lt;td>yes (through the materialised view)&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The middle rung is the only one where both coexist, and therefore the only place you can step back from without losing anything. The code itself offers it as the remedy when something breaks in &lt;code>events_only&lt;/code> (&lt;code>web/src/pages/api/public/ingestion.ts:296&lt;/code>):&lt;/p>
&lt;blockquote>
&lt;p>As a temporary migration bridge, set &lt;code>LANGFUSE_MIGRATION_V4_WRITE_MODE=dual&lt;/code> on both the web and worker services and redeploy.&lt;/p>
&lt;/blockquote>
&lt;p>The &lt;em>both&lt;/em> in that sentence matters. All three packages read the variable and the code comment insists three separate times on keeping them in sync. Deploying the worker with one value and the web with another is the first way to break this.&lt;/p>
&lt;h2 id="the-three-combinations-that-refuse-to-start">The three combinations that refuse to start&lt;/h2>
&lt;p>The worker validates its configuration at boot and throws in three cases, under a comment that leaves no doubt (&lt;code>worker/src/env.ts:718&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// Hard errors: combinations that would silently lose data.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">mode&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s2">&amp;#34;legacy&amp;#34;&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">otel&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s2">&amp;#34;direct&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">throw&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nb">Error&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;Invalid V4 config: LANGFUSE_MIGRATION_V4_NATIVE_OTEL_BEHAVIOUR=direct &amp;#34;&lt;/span> &lt;span class="o">+&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;requires LANGFUSE_MIGRATION_V4_WRITE_MODE in {dual, events_only}. &amp;#34;&lt;/span> &lt;span class="o">+&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;Direct OTel writes target events_full, which is not read in legacy mode.&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The second one bites in a real migration: &lt;code>events_only&lt;/code> also requires &lt;code>LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN=true&lt;/code>, because otherwise the web reads would target the legacy tables that mode no longer writes. The third forbids &lt;code>events_only&lt;/code> with OTel behaviour set to dual.&lt;/p>
&lt;p>A failed boot is the good news. A failed boot shows up in thirty seconds. The combinations it does not validate take days to surface.&lt;/p>
&lt;h2 id="what-breaks-in-each-mode">What breaks in each mode&lt;/h2>
&lt;p>This is the table to have in front of you before moving the variable, because the two ends break different things.&lt;/p>
&lt;p>&lt;strong>In &lt;code>legacy&lt;/code>&lt;/strong> the event tables are not written, so whatever depends on them disappears. Monitor routes return 404 through a dedicated middleware (&lt;code>web/src/server/api/trpc.ts:413&lt;/code>). Metrics v2 is unavailable. Dashboard widgets stay at version 1 (&lt;code>DashboardService.ts:39&lt;/code>). The v4 preview is forced off and cannot be switched on.&lt;/p>
&lt;p>&lt;strong>In &lt;code>events_only&lt;/code>&lt;/strong> the legacy tables are not written, so twenty-four REST routes short-circuit with a 404 (&lt;code>web/src/features/public-api/server/createAuthedProjectAPIRoute.ts:319&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// Short-circuit routes that read from legacy traces/observations tables
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// when the deployment is in events_only mode — those tables are no longer
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// populated, so the response would be stale or empty.
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The list covers &lt;code>traces&lt;/code>, &lt;code>traces/{id}&lt;/code>, &lt;code>observations&lt;/code>, &lt;code>observations/{id}&lt;/code>, &lt;code>sessions&lt;/code>, &lt;code>sessions/{id}&lt;/code>, &lt;code>scores&lt;/code> and &lt;code>scores/{id}&lt;/code> in v1 and v2, &lt;code>metrics&lt;/code>, &lt;code>metrics/daily&lt;/code>, &lt;code>spans&lt;/code>, &lt;code>generations&lt;/code>, &lt;code>events&lt;/code>, &lt;code>dataset-run-items&lt;/code> and the two &lt;code>datasets/{name}/runs&lt;/code> routes. If you have your own integrations against those routes, they break on cutover day, and returning 404 rather than stale data is the right call even though it stings.&lt;/p>
&lt;p>One detail that is usually reported wrongly: &lt;strong>&lt;code>/api/public/ingestion&lt;/code> is never shut down&lt;/strong>. The deprecation notice says so itself (&lt;code>deprecations.ts:104&lt;/code>):&lt;/p>
&lt;blockquote>
&lt;p>This route is never removed: scores (and sdk-log) stay accepted.&lt;/p>
&lt;/blockquote>
&lt;p>In &lt;code>events_only&lt;/code> that route stays alive and keeps accepting two event types (&lt;code>web/src/pages/api/public/ingestion.ts:274&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">EVENTS_ONLY_ALLOWED_TYPES&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">Set&lt;/span>&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">string&lt;/span>&lt;span class="p">&amp;gt;([&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">eventTypes&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SCORE_CREATE&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">eventTypes&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SDK_LOG&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The rest are rejected &lt;strong>per event, not per batch&lt;/strong>: the batch comes back as a 207 with 400s inside. An old client sending traces and scores mixed together will see the scores go through and the rest fail, which is exactly the kind of partial failure that takes a long time to notice.&lt;/p>
&lt;h2 id="the-three-steps-in-order">The three steps, in order&lt;/h2>
&lt;p>Given all of the above, the order of a no-window migration is this:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Finish the v3 background migrations before upgrading.&lt;/strong> This comes before everything else and there are no shortcuts. v4 deletes the rows of seven old migrations, and the comment explains why (&lt;code>prisma/migrations/20260723124010_drop_dataset_run_items_table/migration.sql:7&lt;/code>): their worker scripts are deleted alongside, so an unfinished row would leave the manager with no script to resolve. If one was halfway, that data is lost and the row disappears quietly.&lt;/li>
&lt;li>&lt;strong>Upgrade to v4 with &lt;code>LANGFUSE_MIGRATION_V4_WRITE_MODE=legacy&lt;/code>&lt;/strong>, apply the ClickHouse and Prisma migrations. Here the installation behaves like v3 and you verify everything still stands.&lt;/li>
&lt;li>&lt;strong>Move to &lt;code>dual&lt;/code>.&lt;/strong> From then on both table families are written and the propagator starts. Let it run for as long as it takes and launch the historic backfill.&lt;/li>
&lt;li>&lt;strong>Once history is complete and verified, move to &lt;code>events_only&lt;/code>&lt;/strong> with the preview opt-in enabled, and only then lift the old rail.&lt;/li>
&lt;/ol>
&lt;p>All the work is between 3 and 4, and it can take weeks. There is no rush: &lt;code>dual&lt;/code> is a stable state.&lt;/p>
&lt;h2 id="the-clickhouse-migrations-backfill-nothing">The ClickHouse migrations backfill nothing&lt;/h2>
&lt;p>There are 96 files in &lt;code>packages/shared/clickhouse/migrations/canonical/&lt;/code>, 48 up and 48 down, which have to be materialised before running (&lt;code>pnpm ch:migrations:materialize&lt;/code>) and cleaned afterwards, including after a failed migration. The runner is golang-migrate.&lt;/p>
&lt;p>The ten v4 ones are 0039 through 0048. The first three create &lt;code>events_full&lt;/code>, &lt;code>events_core&lt;/code> and the materialised view that feeds the second from the first. The three drop migrations remove &lt;code>event_log&lt;/code>, &lt;code>project_environments&lt;/code> and &lt;code>dataset_run_items&lt;/code>. The ones in between add columns and indexes.&lt;/p>
&lt;p>What matters for planning is what they do &lt;strong>not&lt;/strong> do. None copies an old row. The materialised view only sees rows inserted into &lt;code>events_full&lt;/code> after it exists. The indexes in migrations 0043 and 0047 are added without materialising historical parts, and 0047 says so in writing:&lt;/p>
&lt;blockquote>
&lt;p>Skip indexes are added without materializing historical parts to keep this migration metadata-only.&lt;/p>
&lt;/blockquote>
&lt;p>Translated into operations: during and after the migration, searches over older data do full scans. Not a bug, the price of a migration that does not block.&lt;/p>
&lt;p>And one cluster trap: migrations 0042, 0043, 0047 and 0048 carry &lt;code>alter_sync = 2&lt;/code>, meaning every &lt;code>ALTER&lt;/code> &lt;strong>waits for all replicas&lt;/strong>. One slow or down replica hangs the whole migration. On a single-replica installation you never notice; on three, you do.&lt;/p>
&lt;h2 id="the-five-step-chain-that-does-backfill-history">The five-step chain that does backfill history&lt;/h2>
&lt;p>The backfill is done by five background migrations running in the worker, registered in Postgres with names that carry the order inside them:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;code>20260701_v4_step_1_create_root_spans_from_traces&lt;/code>: creates virtual root spans in &lt;code>events_full&lt;/code> from existing traces, with the identifier &lt;code>concat('t-', t.id)&lt;/code>. It skips traces referenced by a dataset run item, which belong to step 4. Chunked by monthly partition.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;code>20260701_v4_step_2_rewrite_observations_to_pid_tid_sorting&lt;/code>: copies &lt;code>observations&lt;/code> into a scratch table with a different key order. Chunked by monthly partition.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;code>20260701_v4_step_3_backfill_events_full_from_observations&lt;/code>: the bulk of it. Chunked &lt;strong>by ClickHouse part&lt;/strong>, not by partition, and the comment explains why that is the only safe granularity:&lt;/p>
&lt;blockquote>
&lt;p>One todo per part keeps each INSERT bounded to a single ClickHouse part (single-digit GBs in most cases), which is the only granularity that&amp;rsquo;s safe to assume self-hoster hardware can chew through without OOM or memory-limit failures.&lt;/p>
&lt;/blockquote>
&lt;/li>
&lt;li>
&lt;p>&lt;code>20260701_v4_step_4_backfill_events_full_from_dataset_run_items&lt;/code>: walks the dataset run items by cursor and writes the root and its descendants. Batches of 200, a cap of 50,000 descendants per item, a 90-day window.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;code>20260701_v4_step_5_drop_pid_tid_sorting_tables&lt;/code>: drops step 2&amp;rsquo;s scratch tables.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>There are only &lt;strong>two switches&lt;/strong> for the five. The first four share &lt;code>LANGFUSE_BACKGROUND_MIGRATION_V4_ENABLE_HISTORIC_BACKFILL&lt;/code>, which ships &lt;code>true&lt;/code>, and the fifth has its own, &lt;code>LANGFUSE_BACKGROUND_MIGRATION_V4_DROP_PID_TID_SORTING_TABLES&lt;/code>, which ships &lt;code>false&lt;/code> on purpose so the scratch tables stay around until the operator confirms the backfill went well.&lt;/p>
&lt;p>&lt;strong>One migration at a time across the whole cluster.&lt;/strong> The manager takes a lock in Postgres, refreshes it with a heartbeat every 15 seconds and treats a lock older than 60 seconds as expired, all inside a serialisable transaction. Adding worker replicas does not speed the backfill up by one minute. That is the first thing that surprises anyone used to scaling out to go faster.&lt;/p>
&lt;p>&lt;strong>They are resumable, and rather better than this usually is.&lt;/strong> Progress lives in a JSONB field on the row, and on resume the worker &lt;strong>re-attaches to the queries the previous worker left running&lt;/strong>, by query id, instead of relaunching them:&lt;/p>
&lt;blockquote>
&lt;p>In-flight queries keep running server-side; the next run re-attaches to them via the persisted queryIds.&lt;/p>
&lt;/blockquote>
&lt;p>On how long they take, the code says nothing. Not the files, not the folder&amp;rsquo;s README, not the migrations. The only time threshold anywhere is a design one: &lt;em>&amp;ldquo;A good threshold is something that takes more than 5 minutes to run&amp;rdquo;&lt;/em>. Any figure in hours or days you read elsewhere, including the one I repeated myself before checking, is not in the code.&lt;/p>
&lt;h2 id="the-failure-that-does-not-block">The failure that does not block&lt;/h2>
&lt;p>This is the finding that changes a runbook the most. When a background migration fails, the manager marks it with &lt;code>failedAt&lt;/code> and moves on. And the predicate it uses to pick the next one excludes exactly the rows carrying &lt;code>failedAt&lt;/code>, so &lt;strong>a failed migration is skipped and does not block the ones after it&lt;/strong>.&lt;/p>
&lt;p>The code acknowledges the gap and explains the patch (&lt;code>worker/src/backgroundMigrations/utils/backfillBase.ts:107&lt;/code>):&lt;/p>
&lt;blockquote>
&lt;p>The &lt;code>BackgroundMigrationManager&lt;/code> has no dependency model — it runs every eligible row in name order and marks each finished/failed independently, so a failed upstream step does NOT by itself stop a downstream step from running on partial data. Each downstream step calls this from its &lt;code>validate()&lt;/code> [&amp;hellip;] Because the next step in turn guards on its own predecessor, a single failure transitively halts the rest of the chain.&lt;/p>
&lt;/blockquote>
&lt;p>The guard exists, but &lt;strong>only in steps 3 and 4&lt;/strong>. Steps 1 and 2 declare no predecessor. A failure of step 1 does not stop step 2 from starting; what saves the situation is that step 3 does check step 2.&lt;/p>
&lt;p>Practical consequence: the only reliable signal that the backfill is going well is to look at the &lt;code>background_migrations&lt;/code> table in Postgres and check that all five rows have &lt;code>finishedAt&lt;/code> and none has &lt;code>failedAt&lt;/code>. And to restart a failed one you clear &lt;code>failedAt&lt;/code> by hand and relaunch with &lt;code>--retry-failed&lt;/code>, as the error message itself says.&lt;/p>
&lt;p>Two concrete failure modes that deserve a place in the runbook:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Step 3 breaks if ClickHouse merges a part midway.&lt;/strong> The message says it all: &lt;em>&amp;ldquo;Part &amp;hellip; no longer active after processing — its rows are in a merged successor part that is not in this run&amp;rsquo;s todo list. Clear failedAt and re-run with state.chunksLoaded=false&amp;rdquo;&lt;/em>. Recovery is manual, editing the JSONB.&lt;/li>
&lt;li>&lt;strong>Step 4 stops dead on one giant trace&lt;/strong>, if it exceeds 50,000 descendants. The three ways out the error offers are raising the cap, skipping the item (and losing that trace entirely) or fixing it by hand in SQL.&lt;/li>
&lt;/ul>
&lt;p>And an asymmetry worth knowing before comparing data: the historic backfill &lt;strong>does not copy trace metadata down to child spans&lt;/strong>, deliberately, whereas the live path does concatenate them. History and new data are not identical.&lt;/p>
&lt;h2 id="where-the-ten-minutes-come-from">Where the ten minutes come from&lt;/h2>
&lt;p>The v3 API warns that its data runs about ten minutes behind (&lt;code>deprecations.ts:16&lt;/code>):&lt;/p>
&lt;blockquote>
&lt;p>Data on this API is delayed by about 10 minutes. Real-time is only OpenTelemetry writes plus v2 observations and v2 metrics reads.&lt;/p>
&lt;/blockquote>
&lt;p>That delay is the sum of four things, all visible in the code:&lt;/p>
&lt;ul>
&lt;li>The staging table partitions on &lt;strong>three-minute&lt;/strong> intervals.&lt;/li>
&lt;li>A partition is treated as closed &lt;strong>four minutes&lt;/strong> after creation: &lt;em>&amp;ldquo;We &amp;rsquo;lock&amp;rsquo; partitions 4min after their creation, i.e. the 15:00:00 partition should stop receiving updates at 15:04:00.&amp;rdquo;&lt;/em>&lt;/li>
&lt;li>The propagation cron runs &lt;strong>every minute&lt;/strong> and only looks at partitions older than &lt;code>LANGFUSE_EXPERIMENT_EVENT_PROPAGATION_PARTITION_DELAY_MINUTES&lt;/code>, which defaults to &lt;strong>10&lt;/strong>.&lt;/li>
&lt;li>That cron has a &lt;strong>global concurrency of one&lt;/strong> across the cluster and processes a single partition per invocation, so a backlog makes the delay grow rather than recover.&lt;/li>
&lt;/ul>
&lt;p>The way out of the delay is not to lower that variable, it is to have clients write over OTLP with direct writes, which is the real-time route. The header that enables it is &lt;code>x-langfuse-ingestion-version: 4&lt;/code>, sent automatically by the Python SDK from 4.0.0 and the JavaScript one from 5.0.0, and a value above 4 is rejected with a 400.&lt;/p>
&lt;h2 id="the-forty-eight-hour-ttl">The forty-eight-hour TTL&lt;/h2>
&lt;p>This is the frightening one, and it appears in no migration guide.&lt;/p>
&lt;p>The staging table carries &lt;code>TTL toDateTime(s3_first_seen_timestamp) + INTERVAL 48 HOUR&lt;/code> with &lt;code>ttl_only_drop_parts = 1&lt;/code>. If the propagator stops for more than forty-eight hours, a long bank holiday for instance, those partitions delete themselves. The data is already in the legacy tables, so an installation in &lt;code>dual&lt;/code> keeps working, but the hole in &lt;code>events_full&lt;/code> is &lt;strong>permanent&lt;/strong>, and no code detects it and none repairs it. The health probe detects &lt;em>stuck&lt;/em>, not &lt;em>lost&lt;/em>.&lt;/p>
&lt;p>The defence is an alert, and one exists for this. The worker health endpoint accepts &lt;code>?failIfEventPropagationStuck=true&lt;/code> and returns 503 if the propagator&amp;rsquo;s heartbeat has not been refreshed for more than &lt;code>LANGFUSE_EVENT_PROPAGATION_STUCK_THRESHOLD_MINUTES&lt;/code>, which defaults to 35. The comment carries a condition to read before putting it in a Kubernetes probe:&lt;/p>
&lt;blockquote>
&lt;p>Probes using this flag MUST set &lt;code>initialDelaySeconds &amp;gt;= 60s&lt;/code> (one cron cycle) [&amp;hellip;] A shorter delay can crash-loop the very restart this check triggers.&lt;/p>
&lt;/blockquote>
&lt;p>Another detail of the same mechanism: the propagation cursor lives &lt;strong>in Redis&lt;/strong>, not in Postgres. If Redis is flushed, the cursor resets and every live partition of the staging table is reprocessed.&lt;/p>
&lt;h2 id="what-can-and-cannot-be-undone">What can and cannot be undone&lt;/h2>
&lt;p>The ClickHouse down migrations exist, and they are honest about what they do. The one for 0039 warns in capitals:&lt;/p>
&lt;blockquote>
&lt;p>WARNING: destructive rollback. The up migration uses IF NOT EXISTS, so this table may predate this migration [&amp;hellip;] Rolling back drops that data regardless of which path created the table.&lt;/p>
&lt;/blockquote>
&lt;p>The ones for the dropped tables recreate the schema and nothing else: &lt;em>&amp;ldquo;Any data held in event_log before the up migration dropped it is NOT restored.&amp;rdquo;&lt;/em>&lt;/p>
&lt;p>But the real point of no return is not in ClickHouse, it is in Postgres. There is a Prisma migration called &lt;code>20260723150000_drop_legacy_tracing_tables&lt;/code> that does exactly what it says:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">DROP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;traces&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">DROP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;observations&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">DROP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;scores&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Prisma Migrate has no down migrations. It applies, and that is that.&lt;/p>
&lt;p>Summarising the real reversibility: the write mode can go down from &lt;code>events_only&lt;/code> to &lt;code>dual&lt;/code> whenever you want, and that is the panic button. What does not come back is what was not written while you were up there: the legacy tables receive nothing retroactively. And step 5 of the chain, the one that drops the scratch tables, ships disabled precisely so you can do forensics if something went wrong.&lt;/p>
&lt;p>One last note on the ground you are standing on. The comment in the export-source policy drops something worth reading twice: &lt;em>&amp;ldquo;legacy-writes-disabled, which in practice only surfaces on self-hosted (Cloud does not run events_only)&amp;rdquo;&lt;/em>. The &lt;code>events_only&lt;/code> path is the one you will use and not the one the vendor&amp;rsquo;s managed service uses. It is less battle-tested in production than &lt;code>dual&lt;/code>.&lt;/p>
&lt;h2 id="how-this-looks-in-code">How this looks in code&lt;/h2>
&lt;p>The minimum runbook, in variables:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Step 2: upgrade to v4 behaving like v3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/span>&lt;span class="o">=&lt;/span>legacy
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_MIGRATION_V4_NATIVE_OTEL_BEHAVIOUR&lt;/span>&lt;span class="o">=&lt;/span>dual_write &lt;span class="c1"># &amp;#34;direct&amp;#34; will not start here&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_BACKGROUND_MIGRATION_V4_ENABLE_HISTORIC_BACKFILL&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">false&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"># Step 3: third rail. Both services, web and worker, together.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/span>&lt;span class="o">=&lt;/span>dual
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_BACKGROUND_MIGRATION_V4_ENABLE_HISTORIC_BACKFILL&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_EVENT_PROPAGATION_STUCK_THRESHOLD_MINUTES&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">35&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"># Step 4: only once all five background_migrations rows have finishedAt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/span>&lt;span class="o">=&lt;/span>events_only
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="c1"># without this it will not start&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_BACKGROUND_MIGRATION_V4_DROP_PID_TID_SORTING_TABLES&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="c1"># last of all&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the query that answers the only question that matters for weeks:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">started_at&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">finished_at&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">failed_at&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">failed_reason&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">state&lt;/span>&lt;span class="o">-&amp;gt;&amp;gt;&lt;/span>&lt;span class="s1">&amp;#39;chunksLoaded&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunks&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">background_migrations&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LIKE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;20260701_v4_%&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="migration-checklist">Migration checklist&lt;/h2>
&lt;ul>
&lt;li>Every v3 background migration is finished before the image is upgraded.&lt;/li>
&lt;li>There is a Postgres and a ClickHouse backup from before the first &lt;code>DROP TABLE&lt;/code>.&lt;/li>
&lt;li>The write mode has the same value in the worker and in the web, and both change together.&lt;/li>
&lt;li>The upgrade went to &lt;code>legacy&lt;/code> first, and the service was verified unchanged.&lt;/li>
&lt;li>There is an alert on &lt;code>?failIfEventPropagationStuck=true&lt;/code>, with &lt;code>initialDelaySeconds&lt;/code> of at least 60 seconds.&lt;/li>
&lt;li>Somebody looks at the &lt;code>background_migrations&lt;/code> table every day while the backfill runs.&lt;/li>
&lt;li>The in-house integrations calling the twenty-four routes that will return 404 have been inventoried.&lt;/li>
&lt;li>Client SDKs are on Python 4.0.0 or JavaScript 5.0.0 at minimum before moving to &lt;code>events_only&lt;/code>.&lt;/li>
&lt;li>Step 5 of the chain is enabled last, and only after verifying the backfill.&lt;/li>
&lt;/ul>
&lt;h2 id="traps">Traps&lt;/h2>
&lt;p>&lt;strong>The default is the end state.&lt;/strong> A v3 installation booting v4 without touching the variable lands in &lt;code>events_only&lt;/code> and stops writing the old tables from the first second.&lt;/p>
&lt;p>&lt;strong>A failed background migration does not block the next one.&lt;/strong> And the first two steps of the chain declare no predecessor.&lt;/p>
&lt;p>&lt;strong>More worker replicas do not speed up the backfill.&lt;/strong> One migration at a time across the cluster, by design.&lt;/p>
&lt;p>&lt;strong>Forty-eight hours of stopped propagator is a permanent hole.&lt;/strong> No detection and no repair.&lt;/p>
&lt;p>&lt;strong>New indexes do not cover old data.&lt;/strong> Historical searches scan until somebody materialises the indexes by hand.&lt;/p>
&lt;p>&lt;strong>On a cluster, every &lt;code>ALTER&lt;/code> waits for all replicas.&lt;/strong> One down replica hangs the migration.&lt;/p>
&lt;p>&lt;strong>Classic ingestion rejects per event.&lt;/strong> A mixed batch returns 207 and half of it is lost without anyone reading the response body.&lt;/p>
&lt;p>&lt;strong>History and the live path do not produce quite the same thing.&lt;/strong> Trace metadata does not descend to children in the backfill.&lt;/p>
&lt;p>&lt;strong>The route you will take is not the one the vendor takes.&lt;/strong> Cloud does not run &lt;code>events_only&lt;/code>.&lt;/p>
&lt;h2 id="the-series-the-ten-articles">The series: the ten articles&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">What goes into a trace&lt;/a>: version 4&amp;rsquo;s data model, limits, precedences, scores, masking and indexes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">Putting LangGraph in front&lt;/a>: instrumenting an agentic platform and the measured cost of one turn.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/">Two well-known agents instrumented&lt;/a>: Open Deep Research and GPT Researcher, with the measured figures of a real request.&lt;/li>
&lt;li>Migrating from version 3 to 4 with no window (this article): the three write-mode steps, the resumable background migrations and where the rollback point of no return sits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/">The worker queues&lt;/a>: the map of all thirty-nine, which pool to dedicate to each group, the per-queue switches, sharding and concurrency.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/clickhouse-capacity-langfuse-v4-bytes-per-observation/">Real ClickHouse capacity and cost&lt;/a>: how to measure bytes per observation with the system tables, the difference between the full table and the listings table, and the merge cost of full-text indexes.&lt;/li>
&lt;li>Retention, deletion and data protection: why a deletion does not free disk, the mask cleaner that ships disabled, the pending deletion queue and the S3 lifecycle that has to be implemented by hand.&lt;/li>
&lt;li>Backup and cross recovery: restore ordering across Postgres, ClickHouse and object storage, what each mismatch breaks, and how far event replay goes.&lt;/li>
&lt;li>Saturation runbook: what to alert on from the queue metrics, the stuck probes, draining through the readiness endpoint and the dead letter queue.&lt;/li>
&lt;li>Getting the data out: the blob storage integration to Parquet, batch exports and the metrics API, to build the data lake.&lt;/li>
&lt;/ol>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">Langfuse v4: what really goes into a trace&lt;/a>: the data model this migration leads to.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a>: the version 3 architecture, which is what you are leaving.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a>: what happens to the gateway during the change.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/">Two well-known agents instrumented&lt;/a>: the volume the migrated installation will have to swallow.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Langfuse 4.35.0 source, commit &lt;code>2ee1908&lt;/code> of 14 September 2026: &lt;code>packages/shared/src/env.ts&lt;/code>, &lt;code>worker/src/env.ts&lt;/code>, &lt;code>web/src/env.mjs&lt;/code>, &lt;code>web/src/features/public-api/server/deprecations.ts&lt;/code>, &lt;code>web/src/pages/api/public/ingestion.ts&lt;/code>, &lt;code>web/src/features/public-api/server/createAuthedProjectAPIRoute.ts&lt;/code>, &lt;code>worker/src/backgroundMigrations/&lt;/code>, &lt;code>worker/src/features/eventPropagation/&lt;/code>, &lt;code>packages/shared/clickhouse/migrations/canonical/&lt;/code>, &lt;code>packages/shared/prisma/migrations/&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/compatibility">Langfuse compatibility and migration&lt;/a>, checked on 14 September 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/native/opentelemetry">OpenTelemetry integration migration to v4&lt;/a>, checked on 14 September 2026.&lt;/li>
&lt;/ul></description></item><item><title>Langfuse v4, day 2 (3 of 10): two well-known agents instrumented, Open Deep Research and GPT Researcher</title><link>https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/</link><pubDate>Mon, 14 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/</guid><description>&lt;blockquote>
&lt;p>Third article in a series of ten about operating Langfuse v4 in production. The first walked through &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what goes into a trace&lt;/a>; the second measured &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">what an agent turn costs&lt;/a> over a minimal graph. This one applies the same to two agents people deploy. Code read on 14 September 2026: Open Deep Research at commit &lt;code>1b7d2e8&lt;/code> and GPT Researcher at &lt;code>6f99857&lt;/code>, with Langfuse 4.15.2 and LangGraph 1.2.11.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>One Open Deep Research request with the default configuration is 1,416 observations and 8.1 MB.&lt;/strong> Measured over a faithful replica of its topology with five concurrent units, six supervisor iterations and ten react calls per researcher, which are its defaults. It all lands in a single trace. And it is a floor, because on the bench the search results are three hundred characters and real ones are much larger.&lt;/p>
&lt;p>&lt;strong>None of those 1,416 is of type AGENT.&lt;/strong> Its nine nodes are called &lt;code>clarify_with_user&lt;/code>, &lt;code>write_research_brief&lt;/code>, &lt;code>research_supervisor&lt;/code>, &lt;code>final_report_generation&lt;/code>, &lt;code>supervisor&lt;/code>, &lt;code>supervisor_tools&lt;/code>, &lt;code>researcher&lt;/code>, &lt;code>researcher_tools&lt;/code> and &lt;code>compress_research&lt;/code>. None contains the string the handler looks for.&lt;/p>
&lt;p>&lt;strong>The parallel fan-out does not break the trace, and the fear that it does is unfounded.&lt;/strong> Open Deep Research forwards the &lt;code>config&lt;/code> to its subgraphs and GPT Researcher passes a fresh one carrying only tags. I tested both patterns: one trace in both cases, because LangChain rebuilds the config from the context variable.&lt;/p>
&lt;p>&lt;strong>GPT Researcher is two products in one repository.&lt;/strong> The multi-agent mode is LangGraph and traces like any graph. The standard mode has no graph, and there every model call opens its own trace: eight calls, eight traces, measured.&lt;/p>
&lt;p>&lt;strong>Its human node does not use &lt;code>interrupt()&lt;/code>, it waits on a websocket.&lt;/strong> The node&amp;rsquo;s observation stays open while the person thinks, so its duration measures human latency. The 95th percentile of that graph means nothing unless that node is excluded.&lt;/p>
&lt;p>&lt;strong>Instrumenting both requires no change to their repositories.&lt;/strong> For Open Deep Research it is enough to import the compiled graph and pass the handler through &lt;code>config&lt;/code>. For GPT Researcher&amp;rsquo;s standard mode you have to wrap with the SDK, because there is nowhere to hook callbacks.&lt;/p>
&lt;h2 id="you-are-here-observe-day-2">You are here: OBSERVE, day 2&lt;/h2>
&lt;p>The previous article produced a formula over a toy graph: &lt;code>4 + 5n&lt;/code> observations per turn, with the byte volume growing with the square of the steps. A formula over a toy is good for understanding the mechanism and no good for sizing anything.&lt;/p>
&lt;p>This article applies it to two real agents, chosen because they are well known and because they break in different ways. The series grows to ten articles and the migration one moves along one place.&lt;/p>
&lt;h2 id="the-analogy-two-factories-one-audit">The analogy: two factories, one audit&lt;/h2>
&lt;p>A traceability audit in a factory that runs the whole process on its own lines is tedious but complete: every station leaves a record, and the auditor reconstructs the part from beginning to end. The volume of paper is enormous and that is the only problem.&lt;/p>
&lt;p>The second factory subcontracts half the process. The auditor sees material going into a building and finished product coming out, with times and quantities, and has nothing about what happens inside. There is little paper. The problem is not the paper either.&lt;/p>
&lt;p>Open Deep Research is the first factory and the problem is volume. GPT Researcher, in its standard mode, is the second, and the problem is that the trace does not carry what you need to know. Both are fixable, with different tools, and confusing them leads to buying disk when what is missing is instrumentation.&lt;/p>
&lt;h2 id="the-two-agents-and-why-these">The two agents, and why these&lt;/h2>
&lt;p>&lt;strong>Open Deep Research&lt;/strong> (&lt;code>langchain-ai/open_deep_research&lt;/code>, 12.7 k stars) is LangChain&amp;rsquo;s reference research agent. All its work goes through LangChain, so callback instrumentation sees it whole.&lt;/p>
&lt;p>&lt;strong>GPT Researcher&lt;/strong> (&lt;code>assafelovic/gpt-researcher&lt;/code>, 28.4 k stars) is the best-known open source research agent and it is not from LangChain. Its multi-agent mode uses LangGraph; its standard mode, which is what the API and the web interface run, does not.&lt;/p>
&lt;p>One is in-house and the other is not, which for a comparison is what matters.&lt;/p>
&lt;h2 id="open-deep-research-the-shape-of-the-graph">Open Deep Research: the shape of the graph&lt;/h2>
&lt;p>Three graphs, two of them nested as subgraphs (&lt;code>src/open_deep_research/deep_researcher.py&lt;/code>):&lt;/p>
&lt;ul>
&lt;li>The root graph, &lt;code>deep_researcher&lt;/code> (line 701), with four nodes: &lt;code>clarify_with_user&lt;/code>, &lt;code>write_research_brief&lt;/code>, &lt;code>research_supervisor&lt;/code> and &lt;code>final_report_generation&lt;/code>.&lt;/li>
&lt;li>The supervisor subgraph (line 353), with &lt;code>supervisor&lt;/code> and &lt;code>supervisor_tools&lt;/code>, iterating between them.&lt;/li>
&lt;li>The researcher subgraph (line 589), with &lt;code>researcher&lt;/code>, &lt;code>researcher_tools&lt;/code> and &lt;code>compress_research&lt;/code>, which is a classic react loop.&lt;/li>
&lt;/ul>
&lt;p>The multiplication lives in &lt;code>supervisor_tools&lt;/code>. When the supervisor decides to delegate, that node launches one researcher subgraph per unit and awaits them together (line 305):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">research_tasks&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">researcher_subgraph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ainvoke&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;researcher_messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">tool_call&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;args&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;research_topic&amp;#34;&lt;/span>&lt;span class="p">])],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;research_topic&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">tool_call&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;args&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;research_topic&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">tool_call&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">allowed_conduct_research_calls&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tool_results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">asyncio&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">gather&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="n">research_tasks&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The detail that decides whether the trace comes out whole is that &lt;code>config&lt;/code> at the end: the node receives LangGraph&amp;rsquo;s configuration and forwards it to the subgraphs, so the callbacks travel and every researcher hangs off the right place.&lt;/p>
&lt;p>The three ceilings in the default configuration (&lt;code>src/open_deep_research/configuration.py&lt;/code>) are &lt;code>max_concurrent_research_units = 5&lt;/code>, &lt;code>max_researcher_iterations = 6&lt;/code> and &lt;code>max_react_tool_calls = 10&lt;/code>. They multiply each other.&lt;/p>
&lt;h2 id="what-one-request-costs-measured">What one request costs, measured&lt;/h2>
&lt;p>I replicated that topology with fake models, the same technique as the previous article: three graphs, the same nesting, the same fan-out with &lt;code>asyncio.gather&lt;/code>, three-hundred-character search results and two-hundred-character model replies. No network and no GPU, so it reproduces anywhere.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Units&lt;/th>
&lt;th>Iterations&lt;/th>
&lt;th>React calls&lt;/th>
&lt;th>Observations&lt;/th>
&lt;th>generation&lt;/th>
&lt;th>chain&lt;/th>
&lt;th>Attribute KB&lt;/th>
&lt;th>Largest observation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>1&lt;/td>
&lt;td>1&lt;/td>
&lt;td>26&lt;/td>
&lt;td>8&lt;/td>
&lt;td>18&lt;/td>
&lt;td>56.8 KB&lt;/td>
&lt;td>3.7 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>2&lt;/td>
&lt;td>3&lt;/td>
&lt;td>92&lt;/td>
&lt;td>26&lt;/td>
&lt;td>66&lt;/td>
&lt;td>257.3 KB&lt;/td>
&lt;td>4.6 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>6&lt;/td>
&lt;td>10&lt;/td>
&lt;td>&lt;strong>1,416&lt;/strong>&lt;/td>
&lt;td>370&lt;/td>
&lt;td>1,046&lt;/td>
&lt;td>&lt;strong>8,108 KB&lt;/strong>&lt;/td>
&lt;td>11.0 KB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last row is the repository&amp;rsquo;s default configuration. One research request, one person pressing a button once, leaves &lt;strong>1,416 observations and eight megabytes&lt;/strong> in the observability backend. In a single trace, under a single identifier.&lt;/p>
&lt;p>Those eight megabytes are a floor, not a ceiling. On the bench each search result takes three hundred characters; a real Tavily result carrying page content takes between ten and a hundred times more, and that text enters the history and gets re-serialised in every later observation of the same researcher, which is the previous article&amp;rsquo;s quadratic growth acting on much larger text.&lt;/p>
&lt;p>Put into capacity arithmetic: a hundred research requests a day, which for a small team is nothing, is 141,600 observations and on the order of a gigabyte a day uncompressed, with toy searches. With real searches, tens of gigabytes. It is another order of magnitude compared with a chat assistant, and it is the reason this article comes before the capacity one.&lt;/p>
&lt;h2 id="zero-agent-observations">Zero AGENT observations&lt;/h2>
&lt;p>Among those 1,416 observations there is not one of type &lt;code>AGENT&lt;/code>. The previous article explained why: the handler decides that type by looking for the string &lt;code>agent&lt;/code> in the class path or the run name. Open Deep Research&amp;rsquo;s nine nodes are &lt;code>clarify_with_user&lt;/code>, &lt;code>write_research_brief&lt;/code>, &lt;code>research_supervisor&lt;/code>, &lt;code>final_report_generation&lt;/code>, &lt;code>supervisor&lt;/code>, &lt;code>supervisor_tools&lt;/code>, &lt;code>researcher&lt;/code>, &lt;code>researcher_tools&lt;/code> and &lt;code>compress_research&lt;/code>.&lt;/p>
&lt;p>They are good names. They describe what each node does and they leave out a word that adds nothing for whoever reads the code. The result is that LangChain&amp;rsquo;s reference research agent, seen from Langfuse, is a tree of chains.&lt;/p>
&lt;p>It is fixable from outside, without touching the repository, by naming the subgraph invocation or renaming the two nodes that represent a model decision, which are &lt;code>supervisor&lt;/code> and &lt;code>researcher&lt;/code>. Without that, any dashboard grouping by observation type will not tell decision work apart from plumbing.&lt;/p>
&lt;h2 id="instrumenting-it-without-touching-the-repository">Instrumenting it without touching the repository&lt;/h2>
&lt;p>Open Deep Research ships as a LangGraph Server graph, with its &lt;code>langgraph.json&lt;/code> pointing at &lt;code>deep_researcher&lt;/code>. There are two ways to get the handler in, depending on how you run it.&lt;/p>
&lt;p>If you invoke it from your own code, the compiled graph is importable and the &lt;code>config&lt;/code> is enough:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse.langchain&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CallbackHandler&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">open_deep_research.deep_researcher&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">deep_researcher&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">trace_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_trace_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">seed&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">request_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">handler&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">trace_context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;trace_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">trace_id&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trace_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;deep-research&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">user&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">request_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;odr&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;research&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">deep_researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ainvoke&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">question&lt;/span>&lt;span class="p">)]},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;callbacks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">handler&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">request_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;max_concurrent_research_units&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;max_researcher_iterations&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Lowering those two ceilings from 5 and 6 to 3 and 4 is not an observability recommendation, it is a product decision that also divides the trace volume by three. Better taken at the same time.&lt;/p>
&lt;p>If you deploy it on LangGraph Server there is no invocation of yours to put the &lt;code>config&lt;/code> into, so the handler gets bound at compile time. The pattern is to wrap the exported graph:&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"># instrumented_graph.py, and point langgraph.json here&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse.langchain&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CallbackHandler&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">open_deep_research.deep_researcher&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">deep_researcher&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">graph&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">deep_researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">with_config&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;callbacks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">CallbackHandler&lt;/span>&lt;span class="p">()]})&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That route loses the per-request seeded trace identifier, because the handler is created once. If the agent uses interrupts, that loss matters, for the reason the previous article measured.&lt;/p>
&lt;h2 id="gpt-researcher-two-products-in-one-repository">GPT Researcher: two products in one repository&lt;/h2>
&lt;p>Here is the interesting part, and it is a trap you fall into with any agent that has grown in layers.&lt;/p>
&lt;p>The repository holds the &lt;code>gpt_researcher&lt;/code> package, which is the engine, and the &lt;code>multi_agents&lt;/code> package, which is an orchestration on top. Only the second uses LangGraph.&lt;/p>
&lt;p>The orchestrator (&lt;code>multi_agents/agents/orchestrator.py:66&lt;/code>) has eight nodes: &lt;code>browser&lt;/code>, &lt;code>planner&lt;/code>, &lt;code>human&lt;/code>, &lt;code>researcher&lt;/code>, &lt;code>writer&lt;/code>, &lt;code>fact_checker&lt;/code>, &lt;code>visualizer&lt;/code> and &lt;code>publisher&lt;/code>, with two conditional loops, one for human review of the plan and one for fact checking. The &lt;code>researcher&lt;/code> node launches in parallel one editor subgraph per section (&lt;code>multi_agents/agents/editor.py:134&lt;/code>), and that subgraph has its own revision loop between &lt;code>reviewer&lt;/code> and &lt;code>reviser&lt;/code>.&lt;/p>
&lt;p>All of that traces like any graph. And the models are called with &lt;code>self.llm.ainvoke(messages, **kwargs)&lt;/code> from the generic provider (&lt;code>gpt_researcher/llm_provider/generic/base.py:365&lt;/code>), which is a LangChain chat model with no explicit config, so it picks up the ambient configuration while running inside a node. The generations show up.&lt;/p>
&lt;p>The standard mode is another thing. The &lt;code>GPTResearcher&lt;/code> class has no graph, no nodes and does not go through LangGraph at any point. It is the mode the API and the web interface use, that is, the one most people run.&lt;/p>
&lt;h2 id="the-myth-of-the-fresh-config">The myth of the fresh config&lt;/h2>
&lt;p>Before going on, something that looks like a bug and is not has to be dismantled, because it shows up in many agents.&lt;/p>
&lt;p>When the &lt;code>researcher&lt;/code> node launches the section subgraphs, it does it like this (&lt;code>multi_agents/agents/editor.py:74&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">final_drafts&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">chain&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ainvoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_create_task_input&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">research_state&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">query&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">title&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;tags&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;gpt-researcher&amp;#34;&lt;/span>&lt;span class="p">]})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">query&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">queries&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">research_results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;draft&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">result&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">asyncio&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">gather&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="n">final_drafts&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That &lt;code>config&lt;/code> is fresh and carries only tags. The method does not even receive the node&amp;rsquo;s configuration, so it could not forward it. The intuitive conclusion is that the subgraphs lose the callbacks and every section ends up in its own trace.&lt;/p>
&lt;p>It does not happen. I set both patterns side by side, Open Deep Research&amp;rsquo;s forwarding the &lt;code>config&lt;/code> and GPT Researcher&amp;rsquo;s passing a fresh one, and both give &lt;strong>one trace with one root span&lt;/strong>. The reason is that LangChain does not take the config you pass as is: it starts from the ambient configuration held in a context variable and overlays the keys you bring. The callbacks are still there, and context variables are copied into the tasks &lt;code>asyncio.gather&lt;/code> creates.&lt;/p>
&lt;p>What does get lost are the parent&amp;rsquo;s tags, replaced by the new list. That is a filtering nuisance, not a broken trace.&lt;/p>
&lt;h2 id="the-standard-mode-eight-calls-eight-traces">The standard mode: eight calls, eight traces&lt;/h2>
&lt;p>With no graph there is no root run, and with no root run every model call is its own tree. Measured with eight standalone calls to a chat model, with the handler passed by hand on each one:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Observations&lt;/td>
&lt;td>8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Distinct traces&lt;/td>
&lt;td>&lt;strong>8&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Root spans&lt;/td>
&lt;td>8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Types&lt;/td>
&lt;td>8 generation&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Eight traces of one observation each, with no relation between them. None of them knows it belongs to the same piece of research. And that is the favourable case, because the standard mode offers no public place to put that handler: the &lt;code>**kwargs&lt;/code> reach &lt;code>ainvoke&lt;/code> from &lt;code>create_chat_completion&lt;/code> (&lt;code>gpt_researcher/utils/llm.py:120&lt;/code>), but the path crosses several internal layers that are not meant as an extension point.&lt;/p>
&lt;p>The route that works without patching the repository is to wrap from outside with the SDK and let the inner calls hang off that:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">get_client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">observe&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gpt_researcher&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">GPTResearcher&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@observe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;gpt-researcher&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">as_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;agent&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">research&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">question&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">request_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">user&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">user&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">request_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;gptr&amp;#34;&lt;/span>&lt;span class="p">]):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">researcher&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">GPTResearcher&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">question&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">report_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;research_report&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="n">researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">conduct_research&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">report&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">write_report&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">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">get_client&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">update_current_span&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&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;length&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">report&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="s2">&amp;#34;sources&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_source_urls&lt;/span>&lt;span class="p">())},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">metadata&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;gptr_costs&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_costs&lt;/span>&lt;span class="p">()},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">report&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With that there is one trace per piece of research, with an &lt;code>agent&lt;/code> observation at the root and the generations hanging inside, in the same tree. Searches and document chunking do not appear, because they do not go through LangChain. If they are needed, they get added with &lt;code>start_as_current_observation(as_type=&amp;quot;tool&amp;quot;)&lt;/code> around your own calls, which is the first of the three ways out of the span filter the previous article described.&lt;/p>
&lt;p>Worth noting &lt;code>researcher.get_costs()&lt;/code>. GPT Researcher keeps its own cost accounting, and putting it in as metadata is the only thing that lets you reconcile it later with what Langfuse computes. When the two figures disagree, it is almost always the gateway&amp;rsquo;s model alias.&lt;/p>
&lt;h2 id="the-human-node-that-stays-open">The human node that stays open&lt;/h2>
&lt;p>The multi-agent mode has a &lt;code>human&lt;/code> node that asks for an opinion on the plan before researching. It does not use LangGraph&amp;rsquo;s &lt;code>interrupt()&lt;/code>. It waits inside the node, reading from the websocket (&lt;code>multi_agents/agents/human.py&lt;/code>).&lt;/p>
&lt;p>The two approaches have opposite consequences for the trace. With &lt;code>interrupt()&lt;/code>, the graph hands control back and the trace splits in two, which is what the previous article measured. Waiting inside the node, the trace is single but that node&amp;rsquo;s observation stays open for as long as the person takes, minutes or hours.&lt;/p>
&lt;p>Neither is wrong. What is wrong is the dashboard that averages durations without knowing which of the two it has in front of it. With the wait inside the node, the 95th percentile of graph duration measures how long a human takes to answer a message, not how long the agent takes. Alert on that metric and the alert fires at lunchtime.&lt;/p>
&lt;p>The practical rule is to exclude human-wait nodes by name from any latency metric, and to measure that wait separately, which tends to be an interesting product metric in its own right.&lt;/p>
&lt;h2 id="what-to-look-at-in-the-trace-once-it-arrives">What to look at in the trace once it arrives&lt;/h2>
&lt;p>With both agents instrumented, and before building any dashboard, there are four checks that are done once and save weeks:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Count the observations of a typical request.&lt;/strong> If it comes out above a thousand, this platform&amp;rsquo;s problem is going to be volume and sampling has to be decided before anything else.&lt;/li>
&lt;li>&lt;strong>Look for more than one root span.&lt;/strong> More than one means there is work being traced outside the tree, and you want to know which before there are thousands.&lt;/li>
&lt;li>&lt;strong>Check that generations carry cost.&lt;/strong> If they come in at zero, it is the model alias, fixed by registering that name with its price.&lt;/li>
&lt;li>&lt;strong>Sort observations by duration and remove the ones waiting on a person.&lt;/strong> What stays at the top is where the time really is.&lt;/li>
&lt;/ol>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>The graph is invoked with a trace identifier seeded per request, not a random one.&lt;/li>
&lt;li>The agent&amp;rsquo;s concurrency and iteration ceilings are set deliberately, knowing they multiply trace volume.&lt;/li>
&lt;li>Nodes representing a model decision carry &lt;code>agent&lt;/code> in the name, or it has been accepted in writing that there will be no observations of that type.&lt;/li>
&lt;li>Work that does not go through LangChain is wrapped with the SDK, or it has been decided that it need not be visible.&lt;/li>
&lt;li>Human-wait nodes are identified and excluded from latency metrics.&lt;/li>
&lt;li>The agent&amp;rsquo;s own cost accounting, if it has one, goes in as metadata so it can be reconciled.&lt;/li>
&lt;li>There is a measured figure of observations per request, taken from your own installation and not from this article.&lt;/li>
&lt;/ul>
&lt;h2 id="traps">Traps&lt;/h2>
&lt;p>&lt;strong>A research agent&amp;rsquo;s defaults are demo values, not production ones.&lt;/strong> Five units times six iterations times ten react calls multiply, and whoever chose them was thinking about report quality, not about your ClickHouse.&lt;/p>
&lt;p>&lt;strong>One repository can hold two agents with two different instrumentation stories.&lt;/strong> Instrumenting the mode the README documents and not the one the API runs is a mistake with no symptom: the traces arrive, just of something else.&lt;/p>
&lt;p>&lt;strong>Passing a fresh config to a subgraph does not break the trace.&lt;/strong> It breaks the tags. The tree survives because the callbacks travel through a context variable.&lt;/p>
&lt;p>&lt;strong>A node waiting on a person contaminates any duration statistic.&lt;/strong> And not obviously, because the node finishes cleanly and flags no error.&lt;/p>
&lt;p>&lt;strong>Good node names and good observation classification are conflicting goals.&lt;/strong> The winner is whoever writes the agent&amp;rsquo;s code, who is normally not you.&lt;/p>
&lt;p>&lt;strong>Eight megabytes per request with three-hundred-character searches.&lt;/strong> With real results, the number your installation produces will not look like this article&amp;rsquo;s, and it will be larger.&lt;/p>
&lt;h2 id="the-series-the-ten-articles">The series: the ten articles&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">What goes into a trace&lt;/a>: version 4&amp;rsquo;s data model, limits, precedences, scores, masking and indexes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">Putting LangGraph in front&lt;/a>: instrumenting an agentic platform and the measured cost of one turn.&lt;/li>
&lt;li>Two well-known agents instrumented (this article): Open Deep Research and GPT Researcher, with the measured figures of a real request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">Migrating from version 3 to 4 with no window&lt;/a>: the three write-mode steps, the resumable background migrations and where the rollback point of no return sits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/">The worker queues&lt;/a>: the map of all thirty-nine, which pool to dedicate to each group, the per-queue switches, sharding and concurrency.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/clickhouse-capacity-langfuse-v4-bytes-per-observation/">Real ClickHouse capacity and cost&lt;/a>: how to measure bytes per observation with the system tables, the difference between the full table and the listings table, and the merge cost of full-text indexes.&lt;/li>
&lt;li>Retention, deletion and data protection: why a deletion does not free disk, the mask cleaner that ships disabled, the pending deletion queue and the S3 lifecycle that has to be implemented by hand.&lt;/li>
&lt;li>Backup and cross recovery: restore ordering across Postgres, ClickHouse and object storage, what each mismatch breaks, and how far event replay goes.&lt;/li>
&lt;li>Saturation runbook: what to alert on from the queue metrics, the stuck probes, draining through the readiness endpoint and the dead letter queue.&lt;/li>
&lt;li>Getting the data out: the blob storage integration to Parquet, batch exports and the metrics API, to build the data lake.&lt;/li>
&lt;/ol>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">Putting LangGraph in front&lt;/a>: the formula and the quadratic growth applied here to two real agents.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">Langfuse v4: what really goes into a trace&lt;/a>: the data model that explains the cost of volume.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing the gateway for a fleet of agents&lt;/a>: the same problem seen from the gateway.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a>: why cost comes out at zero with a gateway alias.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">Durable execution and the cost of agents&lt;/a>: what happens when the agent outlives the process.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents per customer&lt;/a>: the isolation around an agent that browses and downloads.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/langchain-ai/open_deep_research">Open Deep Research&lt;/a> source, commit &lt;code>1b7d2e8&lt;/code>, read on 14 September 2026: &lt;code>src/open_deep_research/deep_researcher.py&lt;/code> and &lt;code>configuration.py&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://github.com/assafelovic/gpt-researcher">GPT Researcher&lt;/a> source, commit &lt;code>6f99857&lt;/code>, read on 14 September 2026: &lt;code>multi_agents/agents/orchestrator.py&lt;/code>, &lt;code>multi_agents/agents/editor.py&lt;/code>, &lt;code>multi_agents/agents/human.py&lt;/code>, &lt;code>gpt_researcher/llm_provider/generic/base.py&lt;/code> and &lt;code>gpt_researcher/utils/llm.py&lt;/code>.&lt;/li>
&lt;li>Own measurement of 14 September 2026 over replicas of both topologies, with LangGraph 1.2.11, langchain-core 1.6.3, Langfuse SDK 4.15.2, &lt;code>InMemorySpanExporter&lt;/code> and fake models, no network.&lt;/li>
&lt;li>GitHub stars for both repositories, checked on 14 September 2026.&lt;/li>
&lt;/ul></description></item><item><title>Langfuse v4, day 2 (2 of 10): putting LangGraph in front, and what one agent turn costs in observations</title><link>https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/</link><pubDate>Sun, 13 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/</guid><description>&lt;blockquote>
&lt;p>Second article in a series of ten about operating Langfuse v4 in production. The first one, &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what really goes into a trace&lt;/a>, walked through the data model by reading the server code. This one measures the other side. Verified against Langfuse 4.15.2 (Python SDK), LangGraph 1.2.11 and langchain-core 1.6.3, with figures taken on 13 September 2026 on a bench with no network.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>One agent turn is four observations plus five per tool call.&lt;/strong> Measured with an in-memory exporter over a minimal ReAct graph: zero tools, 4 observations; one, 9; five, 29; ten, 54; forty, 204. The formula holds exactly across the whole range.&lt;/p>
&lt;p>&lt;strong>The byte volume grows quadratically, not linearly.&lt;/strong> The same turn goes from 12.2 KB with one tool call to 1.87 MB with forty. 88 % of those bytes is the input field, because every observation re-serialises the full message history as it stands at that moment.&lt;/p>
&lt;p>&lt;strong>Tool definitions travel attached to every model call.&lt;/strong> The handler appends them to the input field as messages with role &lt;code>tool&lt;/code>. Measured: 364 bytes per tool. With twenty tools that is 7.3 KB on every single step of the loop.&lt;/p>
&lt;p>&lt;strong>The AGENT observation type is assigned by string match on the name.&lt;/strong> A node called &lt;code>planificador&lt;/code> comes out as &lt;code>CHAIN&lt;/code>; the same node called &lt;code>agente_planificador&lt;/code> comes out as &lt;code>AGENT&lt;/code>. The logic is literally looking for &lt;code>agent&lt;/code> in the class path or in the node name (&lt;code>langfuse/langchain/CallbackHandler.py:397&lt;/code>).&lt;/p>
&lt;p>&lt;strong>An interrupt with a resume produces two separate traces.&lt;/strong> Verified with &lt;code>invoke&lt;/code>, with &lt;code>stream&lt;/code> and with subgraphs, and also reusing the same handler object. The SDK ships machinery to reattach the trace, but it only fires if the root run finishes with a control-flow exception, and in LangGraph 1.2.11 the interrupt comes back through the return value.&lt;/p>
&lt;p>&lt;strong>The fix fits in one line and is measured.&lt;/strong> Passing &lt;code>trace_context={&amp;quot;trace_id&amp;quot;: Langfuse.create_trace_id(seed=thread_id)}&lt;/code> when building the handler leaves the interrupt and the resume in the same trace, even with different handlers and therefore with different replicas.&lt;/p>
&lt;p>&lt;strong>The span processor drops by default whatever it does not recognise.&lt;/strong> It only exports spans from the SDK&amp;rsquo;s own tracer, spans carrying some &lt;code>gen_ai.*&lt;/code> attribute, or spans from a closed list of thirty-five instrumentation prefixes (&lt;code>langfuse/_client/span_filter.py:11&lt;/code>). Your own code&amp;rsquo;s spans do not arrive unless they come through one of those three doors.&lt;/p>
&lt;h2 id="you-are-here-observe-day-2">You are here: OBSERVE, day 2&lt;/h2>
&lt;p>The series was planned with eight articles and has grown to ten. The reason is ordering: the next two are about migrating and about sizing ClickHouse, and both need an input figure that nobody publishes, which is how many observations and how many bytes a real unit of work produces. A chat produces a trace with two observations. An agent produces two hundred. Sizing with the chat figure leads to a cluster that falls over the day someone connects the agent.&lt;/p>
&lt;p>So before migrating, measure. And to measure there has to be in front what people actually deploy.&lt;/p>
&lt;h2 id="the-analogy-the-file-that-gets-photocopied-whole-at-every-signature">The analogy: the file that gets photocopied whole at every signature&lt;/h2>
&lt;p>A procurement file passes across six desks. At each desk somebody reads what is there, adds a sheet and passes it on.&lt;/p>
&lt;p>There are two ways to leave a record of that route. The first is to write in a register which desk touched it, when, and which sheet it added. Six short entries. The second is to photocopy the whole file on the way into each desk and on the way out, and to archive the twelve copies. The register takes one sheet. The copies take, at the sixth desk, six times what they took at the first, and in total they take something on the order of the square of the number of desks.&lt;/p>
&lt;p>LangChain&amp;rsquo;s callback instrumentation does the second thing. It does not record the delta of each step, it records the complete state on the way into and out of each step. With a chat that makes no difference, because the state is short and there is one step. With a forty-step agent, the archive weighs almost two megabytes per file processed.&lt;/p>
&lt;p>This is not a defect that needs fixing. It is what lets you open a trace and see exactly what the model saw at step 17 without reconstructing anything. But it drives the sizing, and the number has to be known before signing the contract.&lt;/p>
&lt;h2 id="which-agentic-platform-is-the-most-used">Which agentic platform is the most used&lt;/h2>
&lt;p>The question has two answers depending on the metric, and both are defensible.&lt;/p>
&lt;p>By PyPI downloads over the last thirty days, checked on 13 September 2026:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Package&lt;/th>
&lt;th>Downloads / 30 days&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>langgraph&lt;/code>&lt;/td>
&lt;td>52.3 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>strands-agents&lt;/code>&lt;/td>
&lt;td>35.2 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>openai-agents&lt;/code>&lt;/td>
&lt;td>22.9 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>crewai&lt;/code>&lt;/td>
&lt;td>19.1 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>google-adk&lt;/code>&lt;/td>
&lt;td>14.5 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>pydantic-ai&lt;/code>&lt;/td>
&lt;td>8.0 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>llama-index&lt;/code>&lt;/td>
&lt;td>5.0 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>agno&lt;/code>&lt;/td>
&lt;td>1.9 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>autogen-agentchat&lt;/code>&lt;/td>
&lt;td>0.6 M&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>By GitHub stars the order flips at the top: CrewAI is at 58.4 k, LangGraph at 38.1 k and the OpenAI Agents SDK at 28.6 k.&lt;/p>
&lt;p>PyPI downloads are inflated by continuous integration and by images that reinstall on every build, so they indicate presence in pipelines rather than agents in production. Stars measure interest, not deployment. With both together, LangGraph is the one that shows up in more pipelines and the one with the most worked-through instrumentation path in Langfuse, so it is the one instrumented here. What follows about volume applies equally to any platform instrumented through LangChain callbacks, because the cost comes from the callback model, not from the graph.&lt;/p>
&lt;h2 id="how-langgraph-gets-into-langfuse">How LangGraph gets into Langfuse&lt;/h2>
&lt;p>The route is worth stating, because the previous article left two open and only one is used here.&lt;/p>
&lt;p>&lt;code>langchain-core&lt;/code> 1.6.3 does not import OpenTelemetry anywhere. The check is direct against the installed wheel and returns not one file. LangGraph does not emit OTLP spans on its own either. What exists is LangChain&amp;rsquo;s callback system, and the Langfuse &lt;code>CallbackHandler&lt;/code> hooks into it.&lt;/p>
&lt;p>That handler does not talk to the classic ingestion API. It creates observations with the v4 SDK, which underneath is OpenTelemetry: &lt;code>LangfuseSpanProcessor&lt;/code> sets up an &lt;code>OTLPSpanExporter&lt;/code> against &lt;code>{base_url}/api/public/otel/v1/traces&lt;/code> (&lt;code>langfuse/_client/span_processor.py:123&lt;/code>). So it is OTLP, but generated by the SDK, not by third-party auto-instrumentation.&lt;/p>
&lt;p>The difference matters for one concrete reason left hanging in the previous article. Because the data passes through the SDK calls, the classic masking function does apply: input, output and metadata all go through &lt;code>_process_media_and_apply_mask&lt;/code> before becoming attributes (&lt;code>langfuse/_client/span.py:534&lt;/code>). With auto-instrumentation of the OpenInference or OpenLLMetry kind, the messages travel in span events and masking does not touch them. For an agent handling customer data inside its tools, that difference decides the route.&lt;/p>
&lt;p>The trade-off is that a callback handler only sees what goes through LangChain. HTTP calls a tool makes on its own, the database query, the &lt;code>subprocess&lt;/code>, none of that shows up. And if you instrument your code with OpenTelemetry so that it does show up, you run into the filter described further down.&lt;/p>
&lt;h2 id="the-observation-type-is-decided-by-the-node-name">The observation type is decided by the node name&lt;/h2>
&lt;p>The first article insisted that v4 has ten observation types and that using the right one changes what the interface knows how to group. The LangChain handler emits six of those ten: &lt;code>tool&lt;/code>, &lt;code>retriever&lt;/code>, &lt;code>generation&lt;/code>, &lt;code>agent&lt;/code>, &lt;code>chain&lt;/code> and &lt;code>span&lt;/code>. It never emits &lt;code>event&lt;/code>, &lt;code>embedding&lt;/code>, &lt;code>evaluator&lt;/code> or &lt;code>guardrail&lt;/code>.&lt;/p>
&lt;p>The assignment is direct for four of them: tool callback, type &lt;code>tool&lt;/code>; retriever callback, type &lt;code>retriever&lt;/code>; model callback, type &lt;code>generation&lt;/code>. For chain callbacks, which is what every node of a LangGraph graph is, the criterion is this (&lt;code>langfuse/langchain/CallbackHandler.py:393&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">elif&lt;/span> &lt;span class="n">callback_type&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;chain&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Detect if it&amp;#39;s an agent by examining class path or name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">serialized&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="s2">&amp;#34;id&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">serialized&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">class_path&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">serialized&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nb">any&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;agent&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">part&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">lower&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">part&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">class_path&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="s2">&amp;#34;agent&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_langchain_run_name&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">serialized&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">kwargs&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="s2">&amp;#34;agent&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">name&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">lower&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="s2">&amp;#34;agent&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s2">&amp;#34;chain&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Measured over the same graph with the node renamed and everything else identical:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Node name&lt;/th>
&lt;th>Tool calls&lt;/th>
&lt;th>Observations&lt;/th>
&lt;th>Types&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>planificador&lt;/code>&lt;/td>
&lt;td>10&lt;/td>
&lt;td>54&lt;/td>
&lt;td>11 generation, 33 chain, 10 tool&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>agente_planificador&lt;/code>&lt;/td>
&lt;td>10&lt;/td>
&lt;td>54&lt;/td>
&lt;td>11 generation, 22 chain, 11 agent, 10 tool&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Eleven observations change type because of eleven characters in a function name. This has two practical consequences. The first is that any dashboard counting &lt;code>AGENT&lt;/code> observations is counting naming conventions. The second is that the interface draws the agent graph when the trace contains any observation of a type other than &lt;code>span&lt;/code>, &lt;code>event&lt;/code> or &lt;code>generation&lt;/code>, and &lt;code>chain&lt;/code> qualifies, so the drawing comes out either way. What changes is what you can filter and aggregate afterwards.&lt;/p>
&lt;p>The operational recommendation is boring and it works: name the nodes that represent a model decision with a name containing &lt;code>agent&lt;/code>, and leave the rest as chains. It is a naming convention promoted to data schema, which is not pretty, but it is what there is and it is stable.&lt;/p>
&lt;h2 id="what-a-turn-costs-measured">What a turn costs, measured&lt;/h2>
&lt;p>The setup is a minimal ReAct graph: one node that calls the model, a &lt;code>ToolNode&lt;/code> with one tool, a conditional edge with &lt;code>tools_condition&lt;/code> and an in-memory checkpointer. The model is langchain-core&amp;rsquo;s &lt;code>GenericFakeChatModel&lt;/code>, which returns a fixed list of messages, so neither network nor GPU is needed and the experiment reproduces on any laptop. The exporter is OpenTelemetry&amp;rsquo;s &lt;code>InMemorySpanExporter&lt;/code>, passed to the client through the &lt;code>span_exporter&lt;/code> parameter, so observations are counted with no Langfuse server in front.&lt;/p>
&lt;p>Result, with the node named &lt;code>planificador&lt;/code>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool calls&lt;/th>
&lt;th>Observations&lt;/th>
&lt;th>generation&lt;/th>
&lt;th>chain&lt;/th>
&lt;th>tool&lt;/th>
&lt;th>Attribute bytes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>3&lt;/td>
&lt;td>0&lt;/td>
&lt;td>4.2 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>9&lt;/td>
&lt;td>2&lt;/td>
&lt;td>6&lt;/td>
&lt;td>1&lt;/td>
&lt;td>12.2 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>29&lt;/td>
&lt;td>6&lt;/td>
&lt;td>18&lt;/td>
&lt;td>5&lt;/td>
&lt;td>64.1 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>54&lt;/td>
&lt;td>11&lt;/td>
&lt;td>33&lt;/td>
&lt;td>10&lt;/td>
&lt;td>173.5 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>20&lt;/td>
&lt;td>104&lt;/td>
&lt;td>21&lt;/td>
&lt;td>63&lt;/td>
&lt;td>20&lt;/td>
&lt;td>541.4 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>30&lt;/td>
&lt;td>154&lt;/td>
&lt;td>31&lt;/td>
&lt;td>93&lt;/td>
&lt;td>30&lt;/td>
&lt;td>1,108 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>40&lt;/td>
&lt;td>204&lt;/td>
&lt;td>41&lt;/td>
&lt;td>123&lt;/td>
&lt;td>40&lt;/td>
&lt;td>1,874 KB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The observation count is exactly &lt;code>4 + 5n&lt;/code>. The five per loop iteration are: the model node, the model call, the conditional edge, the tools node and the tool. The four fixed ones are the root graph, the first partial pass and the close.&lt;/p>
&lt;p>The bytes do not follow that line. From 10 to 20 steps the volume multiplies by 3.1; from 20 to 40, by 3.5. That is the quadratic growth expected when each of the &lt;code>n&lt;/code> steps drags along a history whose length is proportional to &lt;code>n&lt;/code>.&lt;/p>
&lt;p>The breakdown by field confirms it. With forty steps, out of 1,709 KB measured in that run, 1,502 KB sit in &lt;code>langfuse.observation.input&lt;/code> and only 56 KB in the output. 88 % of what is ingested is the same history repeated at different stages of progress. The largest single observation in that turn is 22.2 KB, well below the 9.5 MB per OTLP span ceiling, so the problem is not the size limit: it is the aggregate.&lt;/p>
&lt;p>And inside the input there is a second multiplier. The handler appends each tool&amp;rsquo;s definition to the generation&amp;rsquo;s input field, as messages with role &lt;code>tool&lt;/code> (&lt;code>langfuse/langchain/CallbackHandler.py:1190&lt;/code>). Measured with three-parameter tools and a one-line description:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Bound tools&lt;/th>
&lt;th>Input field bytes of one generation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0&lt;/td>
&lt;td>34&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>398&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>1,854&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>3,674&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>20&lt;/td>
&lt;td>7,334&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That is 364 bytes per tool, on every generation, always. An agent with twenty tools and forty steps ingests 7.3 KB × 41 generations = 300 KB in tool schemas alone, which never change over the course of the turn.&lt;/p>
&lt;h2 id="what-this-means-for-clickhouse">What this means for ClickHouse&lt;/h2>
&lt;p>A honest caveat belongs here. I compressed the full payload of those runs with ZSTD level 3, which is what &lt;code>events_full&lt;/code> uses, and got ratios between 29× and 86×, improving the longer the turn. That number is inflated by how synthetic the bench is: my tool always returns the same string, so the redundancy is higher than in production.&lt;/p>
&lt;p>What does hold is the shape. Repeating the history is exactly the pattern columnar compression absorbs well, so the ClickHouse disk is not where it hurts. Where it hurts is earlier: in the bandwidth towards the OTLP endpoint, in the worker CPU that decompresses and flattens, and in the ingestion queue, all of which see uncompressed bytes. The first article noted that the queue that saturates first is &lt;code>langfuse.queue.ingestion.depth&lt;/code>, and this is why it saturates.&lt;/p>
&lt;p>Quick sizing arithmetic, with the measured figures and an average ten-step turn:&lt;/p>
&lt;ul>
&lt;li>1,000 turns a day is 54,000 observations and around 170 MB uncompressed a day.&lt;/li>
&lt;li>10,000 turns a day is 540,000 observations and 1.7 GB uncompressed a day.&lt;/li>
&lt;li>If the average turn goes from ten steps to twenty, observations double and bytes triple.&lt;/li>
&lt;/ul>
&lt;p>Sensitivity to the number of steps is the parameter to watch, more than the number of users. A prompt change that makes the agent take three more turns around the loop multiplies the observability bill with nobody touching the infrastructure. Article 6 of the series comes back to this with ClickHouse system tables to measure the real bytes per observation in your own installation, which is the only thing that replaces this estimate.&lt;/p>
&lt;h2 id="interrupts-two-traces-where-there-should-be-one">Interrupts: two traces where there should be one&lt;/h2>
&lt;p>A production agent with human approval uses &lt;code>interrupt()&lt;/code>, hands control back, waits for a person and resumes with &lt;code>Command(resume=...)&lt;/code>. The operational question is whether the trace survives that pause.&lt;/p>
&lt;p>The SDK ships explicit machinery for the case. There is a store of pending trace contexts keyed by &lt;code>thread_id&lt;/code>, capped at 1,024 entries with eviction of the oldest (&lt;code>MAX_PENDING_RESUME_TRACE_CONTEXTS&lt;/code>), and a function that recognises a &lt;code>Command&lt;/code> carrying &lt;code>resume&lt;/code> and recovers the stored context.&lt;/p>
&lt;p>Measured, it does not fire in this combination of versions:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Case&lt;/th>
&lt;th>Same trace?&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Same handler object, &lt;code>invoke&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Same handler object, &lt;code>stream&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Interrupt inside a subgraph&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fresh handler for the resume&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The reason is in the code and it is consistent. The pending context is only stored from &lt;code>on_chain_error&lt;/code> when the failing run is the root one and the resulting level is &lt;code>DEFAULT&lt;/code>, which is what happens with LangGraph&amp;rsquo;s control-flow exceptions (&lt;code>CallbackHandler.py:866&lt;/code>). In LangGraph 1.2.11 the interrupt does not leave the root as an exception: the graph returns normally, with the interrupt in the return value. The root run ends through &lt;code>on_chain_end&lt;/code> and the branch that stores the context never runs. The interrupted node&amp;rsquo;s observation does come out right: level &lt;code>DEFAULT&lt;/code> and the &lt;code>Interrupt&lt;/code> in the status message, not marked as an error, which is the correct behaviour.&lt;/p>
&lt;p>The fix is verified and does not depend on the SDK changing:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse.langchain&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CallbackHandler&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">trace_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_trace_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">seed&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">handler&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">trace_context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;trace_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">trace_id&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With that, the interrupt and the resume land in the same trace even if the handler is a different object and even if another replica serves the resume, because the identifier derives from the &lt;code>thread_id&lt;/code> and not from in-memory state. It is the same technique the documentation recommends for joining several agents into one trace, applied to joining an agent with itself.&lt;/p>
&lt;p>With one nuance to decide deliberately. Seeding with the bare &lt;code>thread_id&lt;/code> puts every turn of that conversation into a single trace, which grows without bound while the conversation stays alive. If what you want is one trace per turn with the resume attached to the right turn, seed with &lt;code>thread_id&lt;/code> plus the turn number, and leave conversation grouping to the session identifier, which is what it is for. Since v4 has no trace table, a huge trace is not a huge row: it is a filter returning many rows, and what suffers is the interface when opening it.&lt;/p>
&lt;h2 id="thread-session-user-and-what-is-only-read-at-the-root">Thread, session, user, and what is only read at the root&lt;/h2>
&lt;p>The handler recognises five metadata keys: &lt;code>langfuse_session_id&lt;/code>, &lt;code>langfuse_user_id&lt;/code>, &lt;code>langfuse_trace_name&lt;/code>, &lt;code>langfuse_tags&lt;/code> and &lt;code>langfuse_prompt&lt;/code>. The first four are read only when &lt;code>parent_run_id&lt;/code> is &lt;code>None&lt;/code>, that is, only on the root run (&lt;code>CallbackHandler.py:587&lt;/code>). Putting them in the config used to invoke a subgraph does nothing.&lt;/p>
&lt;p>The recommended route, and the one that works without surprises, is the context manager:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trace_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;agent-turn&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">user&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;support-l1&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">payload&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What LangGraph contributes on its own arrives anyway. In the measured run these show up as observation metadata: &lt;code>langgraph_node&lt;/code>, &lt;code>langgraph_step&lt;/code>, &lt;code>langgraph_triggers&lt;/code>, &lt;code>langgraph_path&lt;/code>, &lt;code>langgraph_checkpoint_ns&lt;/code>, &lt;code>checkpoint_ns&lt;/code> and &lt;code>thread_id&lt;/code>, plus &lt;code>ls_provider&lt;/code>, &lt;code>ls_model_type&lt;/code> and &lt;code>ls_integration&lt;/code>. The &lt;code>thread_id&lt;/code> is also promoted to trace metadata.&lt;/p>
&lt;p>That is fine, but keeping in mind the first article: in &lt;code>events_core&lt;/code> what is indexed is &lt;code>metadata_names&lt;/code>, that is the key names, not the values. Filtering by &lt;code>langgraph_node = &amp;quot;planificador&amp;quot;&lt;/code> is a scan. If you are going to segment by node routinely, the right place is the observation name, which is where the node already appears, or a tag, assuming that tags are not indexed either. The cheap field for slicing by node in v4 is the observation name itself.&lt;/p>
&lt;p>The session identifier deserves an explicit decision. LangGraph&amp;rsquo;s &lt;code>thread_id&lt;/code> and Langfuse&amp;rsquo;s &lt;code>session_id&lt;/code> are the same concept and they do not connect on their own. Mapping them one to one is the reasonable thing, and it is also what makes the whole conversation navigable in the interface even when each turn is a separate trace.&lt;/p>
&lt;h2 id="the-filter-that-drops-your-own-spans">The filter that drops your own spans&lt;/h2>
&lt;p>This is the finding that surprises most people while setting it up. The Langfuse span processor does not export everything that passes through the &lt;code>TracerProvider&lt;/code>. It exports what satisfies one of these three conditions (&lt;code>langfuse/_client/span_filter.py:104&lt;/code>):&lt;/p>
&lt;ol>
&lt;li>It was created by the Langfuse SDK&amp;rsquo;s own tracer.&lt;/li>
&lt;li>It carries at least one attribute starting with &lt;code>gen_ai&lt;/code>.&lt;/li>
&lt;li>Its instrumentation scope matches one of thirty-five prefixes in a closed list.&lt;/li>
&lt;/ol>
&lt;p>The list covers the expected and a bit more: &lt;code>openinference&lt;/code>, &lt;code>litellm&lt;/code>, &lt;code>haystack&lt;/code>, &lt;code>langsmith&lt;/code>, &lt;code>strands-agents&lt;/code>, &lt;code>pydantic-ai&lt;/code>, &lt;code>autogen-core&lt;/code>, &lt;code>vllm&lt;/code> and a couple of dozen &lt;code>opentelemetry.instrumentation.*&lt;/code> entries.&lt;/p>
&lt;p>The consequence for an agent is direct. If you instrument your tools with OpenTelemetry by hand so that the ERP call or the Postgres query shows up inside the agent&amp;rsquo;s trace, those spans are dropped silently, with no log and no error. There are three ways out, in order of cleanliness:&lt;/p>
&lt;ul>
&lt;li>Create those observations with the Langfuse SDK, with &lt;code>start_as_current_observation(as_type=&amp;quot;tool&amp;quot;)&lt;/code>, which is what the rest of this article assumes.&lt;/li>
&lt;li>Put some &lt;code>gen_ai.*&lt;/code> attribute on the span, which is abusing the convention to smuggle the span through, so only if the span does describe an interaction with a model.&lt;/li>
&lt;li>Send those spans to your normal OpenTelemetry collector instead of Langfuse, and correlate by trace identifier. For a sovereign platform with its own collector, this is the sensible option: Langfuse keeps the model side, the collector keeps the system side, and the trace identifier joins them.&lt;/li>
&lt;/ul>
&lt;p>The behaviour being silent is what makes this take an afternoon to find. It belongs in the runbook.&lt;/p>
&lt;h2 id="the-model-and-the-cost-with-a-gateway-in-the-middle">The model and the cost with a gateway in the middle&lt;/h2>
&lt;p>One detail that affects anyone putting LiteLLM in front, which is the setup described in &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the operational pair&lt;/a> and in &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">GPU multi-tenancy&lt;/a>.&lt;/p>
&lt;p>The model name the handler puts on the generation comes first from the &lt;code>ls_model_name&lt;/code> metadata and, failing that, from the component&amp;rsquo;s serialisation (&lt;code>CallbackHandler.py:1297&lt;/code>). With a &lt;code>ChatOpenAI&lt;/code> pointed at the gateway, that name is whatever alias you defined in LiteLLM, for example &lt;code>qwen-32b-internal&lt;/code>. Langfuse computes cost from its own model table, which does not know that alias, so the generation arrives with tokens and without cost.&lt;/p>
&lt;p>It is fixed by defining the model in Langfuse under that same name with its price per million tokens, which is also the only way for the cost to reflect what your GPU costs rather than what somebody else&amp;rsquo;s API costs. It is an afternoon of work and it stops the agentic platform&amp;rsquo;s cost panel from sitting at zero forever.&lt;/p>
&lt;h2 id="how-this-looks-in-code">How this looks in code&lt;/h2>
&lt;p>The minimum to have in place for a LangGraph agent to produce useful traces in Langfuse v4:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">os&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">typing&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Annotated&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">TypedDict&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">get_client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse.langchain&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CallbackHandler&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">langchain_core.messages&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">HumanMessage&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">langgraph.graph&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">StateGraph&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">START&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">langgraph.graph.message&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">add_messages&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">langgraph.prebuilt&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">ToolNode&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tools_condition&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"># 1. Client. Masking applies because the data goes through the SDK.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">mask&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">kwargs&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">redact_pii&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&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">Langfuse&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">public_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;LANGFUSE_PUBLIC_KEY&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">secret_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;LANGFUSE_SECRET_KEY&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">host&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;LANGFUSE_HOST&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">mask&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">mask&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">environment&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;prod&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># lowercase, dashes, 40 chars, must not start with &amp;#34;langfuse&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sample_rate&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">getenv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;LANGFUSE_SAMPLE_RATE&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;1.0&amp;#34;&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 2. Graph. The decision node carries &amp;#34;agent&amp;#34; in its name on purpose.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">State&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TypedDict&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">messages&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Annotated&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">list&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">add_messages&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">graph&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">StateGraph&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">State&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;agent_planner&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">model_node&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;tools&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ToolNode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TOOLS&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">START&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;agent_planner&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_conditional_edges&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;agent_planner&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tools_condition&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;tools&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;agent_planner&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">app&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">compile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">checkpointer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">checkpointer&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"># 3. Per turn: deterministic trace seeded with thread and turn, session = thread.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">run_turn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">turn&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">text&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">user&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trace_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_trace_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">seed&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">turn&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">handler&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">trace_context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;trace_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">trace_id&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;callbacks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">handler&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">thread_id&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trace_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;agent-turn&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">user&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;agent&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;support&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&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">trace_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">out&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"># 4. Resume after human approval: same seed, same trace.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">resume_turn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">turn&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">decision&lt;/span>&lt;span class="p">):&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">langgraph.types&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Command&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">trace_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_trace_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">seed&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">turn&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">handler&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">trace_context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;trace_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">trace_id&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;callbacks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">handler&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">thread_id&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">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resume&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">decision&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&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"># 5. Score the turn. Scores do not travel over OTLP: they have their own route.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">get_client&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_score&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trace_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">trace_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;resolved&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">data_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;BOOLEAN&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;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>Two notes on what is not in the example. &lt;code>sample_rate&lt;/code> is head sampling and per trace, so it samples whole turns, which is what you want: half an agent trace is no use to anybody. And if the process is a web server, the client is built once at startup, not per request, whereas the handler can be per turn with no problem, because with the seeded identifier it no longer holds state worth preserving.&lt;/p>
&lt;h2 id="agent-instrumentation-checklist">Agent instrumentation checklist&lt;/h2>
&lt;ul>
&lt;li>Nodes representing a model decision carry &lt;code>agent&lt;/code> in the name. The rest do not.&lt;/li>
&lt;li>Every turn has a deterministic trace identifier seeded with thread and turn number.&lt;/li>
&lt;li>LangGraph&amp;rsquo;s &lt;code>thread_id&lt;/code> goes as Langfuse&amp;rsquo;s &lt;code>session_id&lt;/code>, always.&lt;/li>
&lt;li>Trace attributes are set with &lt;code>propagate_attributes&lt;/code>, not as metadata on a subgraph.&lt;/li>
&lt;li>The gateway&amp;rsquo;s model alias is registered in the Langfuse model table, with a price.&lt;/li>
&lt;li>Tools doing work outside LangChain are instrumented with the Langfuse SDK, not with bare OpenTelemetry.&lt;/li>
&lt;li>There is an alert on ingestion queue depth, and the average number of steps per turn is known.&lt;/li>
&lt;li>Someone has measured, on the actual installation, how many observations and how many bytes a real turn produces. The figures in this article come from a synthetic bench.&lt;/li>
&lt;li>Sampling is set through an environment variable, so it can be lowered without deploying code.&lt;/li>
&lt;/ul>
&lt;h2 id="traps">Traps&lt;/h2>
&lt;p>&lt;strong>Type AGENT does not mean there is an agent.&lt;/strong> It means somebody wrote &lt;code>agent&lt;/code> in the name. And the other way around: a whole graph without that string produces not one agent-type observation.&lt;/p>
&lt;p>&lt;strong>Putting &lt;code>langfuse_session_id&lt;/code> in a subgraph&amp;rsquo;s metadata does nothing.&lt;/strong> It is only read on the root run.&lt;/p>
&lt;p>&lt;strong>An interrupt splits the trace in two.&lt;/strong> Unless you seed the identifier. And with several replicas, the handler&amp;rsquo;s in-memory state is never going to save you, because the resume can land on another pod.&lt;/p>
&lt;p>&lt;strong>Your OpenTelemetry spans do not arrive.&lt;/strong> The default filter drops them without warning if the scope is not in the list and there are no &lt;code>gen_ai.*&lt;/code> attributes.&lt;/p>
&lt;p>&lt;strong>The number to watch is not users, it is steps per turn.&lt;/strong> Volume grows with the square of the steps, and the steps are decided by the prompt, not by the infrastructure.&lt;/p>
&lt;p>&lt;strong>You store megabytes and the interface shows you a thousand characters.&lt;/strong> The materialised view&amp;rsquo;s truncation to two hundred characters and the thousand-character read ceiling are still there. You pay for the full ingestion and see a clipping, unless you open the specific observation.&lt;/p>
&lt;p>&lt;strong>Cost comes out at zero with the gateway in front.&lt;/strong> Model alias unknown to the price table.&lt;/p>
&lt;p>&lt;strong>&lt;code>flush_at&lt;/code> above the queue size blows up when constructing the client.&lt;/strong> With an OpenTelemetry &lt;code>ValueError&lt;/code> that does not mention Langfuse anywhere.&lt;/p>
&lt;h2 id="the-series-the-ten-articles">The series: the ten articles&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">What goes into a trace&lt;/a>: version 4&amp;rsquo;s data model, limits, precedences, scores, masking and indexes.&lt;/li>
&lt;li>Putting LangGraph in front (this article): instrumenting an agentic platform and the measured cost of one turn.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/">Two well-known agents instrumented&lt;/a>: Open Deep Research and GPT Researcher, with the measured figures of a real request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">Migrating from version 3 to 4 with no window&lt;/a>: the three write-mode steps, the resumable background migrations and where the rollback point of no return sits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/">The worker queues&lt;/a>: the map of all thirty-nine, which pool to dedicate to each group, the per-queue switches, sharding and concurrency.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/clickhouse-capacity-langfuse-v4-bytes-per-observation/">Real ClickHouse capacity and cost&lt;/a>: how to measure bytes per observation with the system tables, the difference between the full table and the listings table, and the merge cost of full-text indexes.&lt;/li>
&lt;li>Retention, deletion and data protection: why a deletion does not free disk, the mask cleaner that ships disabled, the pending deletion queue and the S3 lifecycle that has to be implemented by hand.&lt;/li>
&lt;li>Backup and cross recovery: restore ordering across Postgres, ClickHouse and object storage, what each mismatch breaks, and how far event replay goes.&lt;/li>
&lt;li>Saturation runbook: what to alert on from the queue metrics, the stuck probes, draining through the readiness endpoint and the dead letter queue.&lt;/li>
&lt;li>Getting the data out: the blob storage integration to Parquet, batch exports and the metrics API, to build the data lake.&lt;/li>
&lt;/ol>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">Langfuse v4: what really goes into a trace&lt;/a>: the data model that explains why volume matters this much.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a>: the four queues where the trace gets lost between gateway and backend.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: why the trace&amp;rsquo;s user is not the Keycloak one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing the gateway for a fleet of agents&lt;/a>: the other side of the same volume problem.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">Durable execution and the cost of agents&lt;/a>: what happens when the agent outlives the process that launched it.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents per customer&lt;/a>: the isolation underneath all of this.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a>: where an agent turn&amp;rsquo;s scores fit.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Own measurement of 13 September 2026: LangGraph 1.2.11, langchain-core 1.6.3 and Langfuse Python SDK 4.15.2, with OpenTelemetry&amp;rsquo;s &lt;code>InMemorySpanExporter&lt;/code> and &lt;code>GenericFakeChatModel&lt;/code>, no network.&lt;/li>
&lt;li>Langfuse SDK 4.15.2 source: &lt;code>langfuse/langchain/CallbackHandler.py&lt;/code>, &lt;code>langfuse/_client/span_filter.py&lt;/code>, &lt;code>langfuse/_client/span_processor.py&lt;/code>, &lt;code>langfuse/_client/span.py&lt;/code>.&lt;/li>
&lt;li>PyPI downloads over the last thirty days via &lt;a href="https://pypistats.org/">pypistats&lt;/a>, checked on 13 September 2026.&lt;/li>
&lt;li>GitHub stars for &lt;a href="https://github.com/langchain-ai/langgraph">LangGraph&lt;/a>, &lt;a href="https://github.com/crewAIInc/crewAI">CrewAI&lt;/a> and the &lt;a href="https://github.com/openai/openai-agents-python">OpenAI Agents SDK&lt;/a>, checked on 13 September 2026.&lt;/li>
&lt;/ul></description></item><item><title>Completing Keycloak for MCP: the protected resource no SDK hands you ready-made, and the enterprise extension that skips the whole flow</title><link>https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/</link><pubDate>Sat, 12 Sep 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/</guid><description>&lt;blockquote>
&lt;p>A follow-up to &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>, which flagged the gap: resource indicators are not supported and protected resource metadata belongs to the MCP server. This post tries to close it. Verified against revision 2026-07-28 of the specification, the official Python and TypeScript SDKs, Keycloak 26.7.3 and LiteLLM 1.102.0.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>The specification splits three obligations and the authorisation server covers only one.&lt;/strong> The MCP server must publish its protected resource metadata. The client must send the resource indicator in both requests, and must do so even if the authorisation server does not support it. The MCP server must validate that the token was issued for it. Keycloak does not understand the indicator and considers the metadata someone else&amp;rsquo;s, so all three end up in the resource layer.&lt;/p>
&lt;p>&lt;strong>The Python SDK mounts the metadata on its own, but degraded.&lt;/strong> As soon as the resource server URL is configured, the route appears. What that automatic route publishes carries a single authorisation server, reuses as its scope catalogue the ones the middleware requires, and leaves the name and the documentation null. To publish a complete document you have to mount the route by hand.&lt;/p>
&lt;p>&lt;strong>Audience validation comes switched off in Python and does not exist in TypeScript.&lt;/strong> In Python, if the resource URL is configured and the corresponding flag is not enabled, the SDK emits a deprecation warning and behaves as if it were disabled; the docstring itself promises that version 3 will set it to true. In TypeScript the bearer verification does three things, and none of them is looking at the audience. Neither of the two ships a signature verifier with public keys.&lt;/p>
&lt;p>&lt;strong>Token passthrough is forbidden in writing, and the substitute has a name.&lt;/strong> The specification says the MCP server must not accept or relay tokens that were not issued for it, and that if it calls upstream APIs the token must be a different one. The mechanism is token exchange, which Keycloak supports in its standard version with a limitation you need to know: the audience is a client identifier, not a resource URL.&lt;/p>
&lt;p>&lt;strong>There is an official extension that changes the whole picture, and it is stable.&lt;/strong> Enterprise-managed authorisation replaces the redirect to the MCP authorisation server with an exchange at the corporate identity provider, which evaluates the policy and issues a grant based on an identity assertion. It has been stable since June 2026 and there are servers in production.&lt;/p>
&lt;p>&lt;strong>Keycloak implements it halfway and in experimental.&lt;/strong> It only knows how to act as receiver, not as issuer, behind a feature flag, and against draft 01 when the working group is on 04. The official documentation literally says not to use it in production.&lt;/p>
&lt;p>&lt;strong>LiteLLM already implements it on the client side and does not document it.&lt;/strong> In 1.102.0 there is an authentication mode towards MCP servers that runs the two stages of that grant. There is not a single documentation file that mentions it.&lt;/p>
&lt;h2 id="you-are-here-the-resource-side">You are here: the resource side&lt;/h2>
&lt;p>The previous post walked through the identity provider. This one walks through what sits on the other side of the token, which is where the MCP specification puts almost all its normative obligations.&lt;/p>
&lt;p>The natural reading order is &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up&lt;/a> for the basic setup, &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> for the identity piece, and this one for the missing layer. The MCP gateway seen from the inside is &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in its own post&lt;/a>.&lt;/p>
&lt;h2 id="the-analogy-the-visa-and-the-border-checkpoint">The analogy: the visa and the border checkpoint&lt;/h2>
&lt;p>A consulate issues visas. A border post checks them. They are two different offices, and the classic mistake consists of assuming that because the visa is genuine it is good for entering through any door.&lt;/p>
&lt;p>The visa has written on it which country it is good for. That is the token&amp;rsquo;s audience, and here the problem appears: the consulate we work with does not know how to write the destination the traveller asks for, because it does not understand that field on the form. It knows how to write a destination if asked for it under a different name, by way of a pre-agreed stamp, which is the workaround of scopes and the audience mapper.&lt;/p>
&lt;p>And then there is the other half, the one almost nobody builds. The border post has to exist, it has to announce where it is and from which consulates it accepts visas, and it has to read the destination written on the visa before letting anyone through. If the border post limits itself to checking that the stamp is genuine and does not look at the destination, any valid visa from the same consulate is good for entering. That is exactly what the two official SDKs do by default.&lt;/p>
&lt;h2 id="part-1-the-split-of-obligations">Part 1. The split of obligations&lt;/h2>
&lt;p>From revision 2026-07-28, with the normative words as they are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Obligation&lt;/th>
&lt;th>Whose&lt;/th>
&lt;th>Status with Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Publish protected resource metadata (RFC 9728)&lt;/td>
&lt;td>MCP server, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>Out of its scope, by design&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Send the resource indicator (RFC 8707) in authorisation and in token&lt;/td>
&lt;td>Client, &lt;strong>MUST&lt;/strong>, even if the authorisation server does not support it&lt;/td>
&lt;td>It does not understand it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Validate that the token was issued for oneself&lt;/td>
&lt;td>MCP server, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>The resource&amp;rsquo;s responsibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Not accept or relay other tokens&lt;/td>
&lt;td>MCP server, &lt;strong>MUST NOT&lt;/strong>&lt;/td>
&lt;td>The resource&amp;rsquo;s responsibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Validate the issuer of the authorisation response (RFC 9207)&lt;/td>
&lt;td>Client, &lt;strong>MUST&lt;/strong>; authorisation server &lt;strong>SHOULD&lt;/strong> emit it&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Consent for each dynamically registered client&lt;/td>
&lt;td>MCP proxy with a static identifier, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>Configurable&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The most confusing part of that split is the second line. The specification obliges the client to send the parameter &lt;strong>regardless of whether the authorisation server supports it&lt;/strong>. With Keycloak, that parameter is lost: the official documentation says it cannot recognise it, and the standard behaviour towards an unknown parameter is to ignore it. The client complies, the token comes out, and what does not come out is the correct audience. The failure appears later and somewhere else, which is the worst kind of failure.&lt;/p>
&lt;p>It is worth knowing where that work stands. The matter is issue 14355 of the project, open, with a milestone in 26.8.0. There was a complete implementation in pull request 35711, with a mapper of its own and an extension point for resolving resources, which &lt;strong>went back to draft in October 2025&lt;/strong> because of a total lack of tests and because it was decided to redo it in phases, starting with a single resource per client. In March 2026 a new issue was opened for experimental support, also with a milestone in 26.8.0 and with no associated pull request. In 26.7.x there is no feature flag that enables it.&lt;/p>
&lt;h3 id="what-is-new-in-the-revision-and-affects-the-setup">What is new in the revision and affects the setup&lt;/h3>
&lt;p>Four things changed with respect to the previous revision and are worth collecting:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>RFC 9207.&lt;/strong> A new section on validating the authorisation response. The client must record the issuer from the validated metadata document, and the comparison is literal: you are not allowed to normalise case, elide the default port, add or remove the trailing slash or re-encode characters before comparing.&lt;/li>
&lt;li>&lt;strong>Offline tokens leave the catalogue.&lt;/strong> A new section on refresh tokens: MCP servers &lt;strong>should not&lt;/strong> include the corresponding scope either in the challenge header or in the supported scopes of their metadata.&lt;/li>
&lt;li>&lt;strong>Dynamic registration becomes deprecated.&lt;/strong> It goes from optional to optional and deprecated, retained for compatibility with authorisation servers that do not support client identifier metadata documents. And a new requirement is added to registration: the application type is mandatory, and a client must not reuse credentials from another authorisation server, it has to register again.&lt;/li>
&lt;li>&lt;strong>Step-up elevation is rewritten.&lt;/strong> The menu of three server strategies disappears and is replaced by two rules: the challenge&amp;rsquo;s scope attribute describes what is needed for the requested resource, with no obligation to include what has already been granted, and scope accumulation becomes the client&amp;rsquo;s responsibility. With a new requirement for the server: it &lt;strong>must&lt;/strong> take scope hierarchies into account, where a broad one implies the narrow ones.&lt;/li>
&lt;/ul>
&lt;h2 id="part-2-what-the-sdks-give-and-what-they-do-not">Part 2. What the SDKs give, and what they do not&lt;/h2>
&lt;p>Here is the uncomfortable part, and it is the main reason for writing this post. Verified by reading the code of the Python SDK and of the TypeScript 2.0.0-alpha one.&lt;/p>
&lt;h3 id="protected-resource-metadata">Protected resource metadata&lt;/h3>
&lt;p>&lt;strong>In Python the complete model exists and it mounts on its own.&lt;/strong> The metadata class carries the RFC fields: resource, authorisation servers with a minimum of one, key set URL, supported scopes, bearer methods with a default value of header, name, documentation, policy, terms, and the client certificate and DPoP fields. The handler serves the document with a one-hour cache directive, and the URL is built by inserting the well-known path in front of the resource path, as the RFC requires.&lt;/p>
&lt;p>As soon as the resource server URL is configured, the route appears with no further work. &lt;strong>But what that automatic route publishes is degraded in three points&lt;/strong>: it passes a single authorisation server, it takes as its scope catalogue the ones the middleware requires (which are not the same as the ones the resource supports), and it passes neither the name nor the documentation, which come out null. To publish a complete document you have to call the route creation function by hand.&lt;/p>
&lt;p>&lt;strong>In TypeScript it exists and does not mount on its own.&lt;/strong> The function that builds the document emits &lt;strong>only five fields&lt;/strong>: resource, authorisation servers, supported scopes, name and documentation. &lt;strong>It does not emit the supported bearer methods&lt;/strong>, nor the key set URL, nor anything about DPoP. And serving it is explicit: either you call the response function from your own handler, or you mount the metadata router. There is no point in the SDK that does it on its own.&lt;/p>
&lt;h3 id="audience-validation">Audience validation&lt;/h3>
&lt;p>This is the one to fix on day one.&lt;/p>
&lt;p>&lt;strong>In Python it comes disabled.&lt;/strong> The token verifier is a single-method protocol, that is to say a hole the implementer fills. The access token&amp;rsquo;s resource field is the indicator you set yourself. And the comparison only happens if you ask for it: there is a function that normalises as a URL and ignores the trailing slash, but the resource server URL reaches it empty unless the validation flag is enabled.&lt;/p>
&lt;p>The detail to read twice is in the configuration: &lt;strong>if the resource URL is set and the flag is not, the SDK raises a deprecation warning and behaves as if the flag were false&lt;/strong>. The docstring says version 3 will set the default to true. Until then, a Python server configured with authorisation accepts tokens issued for another resource, unless the implementer enables the flag or validates the audience inside their own verifier.&lt;/p>
&lt;p>&lt;strong>In TypeScript it is not validated at all.&lt;/strong> Bearer token verification does exactly three things: strip the prefix, check the required scopes, and require that the expiry is present and not past. A search for audience or key set across the server and middleware packages returns no validation. It is all left in the hands of whichever verifier you plug in.&lt;/p>
&lt;p>&lt;strong>Neither of the two ships a signature verifier with public keys.&lt;/strong> In Python the only example is introspection against the authorisation server, and its audience check sits behind a flag that also comes out false. In TypeScript the interface is empty. That is: the component that validates the token, which is the one holding up the most important normative requirement of the specification, is your own code in both cases.&lt;/p>
&lt;h3 id="the-authentication-challenge">The authentication challenge&lt;/h3>
&lt;p>&lt;strong>Python&lt;/strong> builds the challenge with the error and its description, and adds the metadata URL only if it is configured. It returns 401 with invalid token and 403 with insufficient scope, both from the same function, so both carry the metadata URL. &lt;strong>What it never emits is the scope parameter&lt;/strong>, which revision 2026-07-28 asks to be included, and which is exactly what the client needs in order to know what to ask for.&lt;/p>
&lt;p>&lt;strong>TypeScript does emit the scope&lt;/strong> when there are required scopes, in addition to the metadata URL, and it correctly maps invalid token to 401 and insufficient scope to 403.&lt;/p>
&lt;p>That is, each SDK gets a different half right. The Python one validates better and warns worse; the TypeScript one warns better and does not validate.&lt;/p>
&lt;h3 id="scopes-which-are-not-per-tool">Scopes, which are not per tool&lt;/h3>
&lt;p>In both SDKs scopes are &lt;strong>a static list at the transport mounting level&lt;/strong>, not per tool. There is no per-tool scope declaration and no step-up elevation help on the server side. Emitting a 403 with the specific scope that call requires, which is what the specification describes, is your own code.&lt;/p>
&lt;p>That is the origin of almost all the fine-grained authorisation you have to build, and it is why there is a Part 6.&lt;/p>
&lt;h3 id="which-revision-each-one-announces">Which revision each one announces&lt;/h3>
&lt;p>&lt;strong>Python is already on 2026-07-28.&lt;/strong> The revision appears in the known versions and in the list of modern versions, described as the ones that use the stateless envelope per request. The server discovery method is registered with a default handler, and the method and name headers are validated against the body.&lt;/p>
&lt;p>&lt;strong>TypeScript has it, but in a separate list.&lt;/strong> The latest protocol version constant is still 2025-11-25, because that list is only the one for the initial handshake. The modern era lives in a separate module, with a comment explaining the reason: keeping them deliberately separate so that adding a revision there never leaks a modern version string into a 2025-era handshake.&lt;/p>
&lt;p>And it is worth recalling the mismatch that already came up &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a>: LiteLLM 1.102.0 still announces 2025-06-18.&lt;/p>
&lt;h2 id="part-3-building-the-protected-resource">Part 3. Building the protected resource&lt;/h2>
&lt;p>With the above, the list of what you have to write is short and concrete.&lt;/p>
&lt;p>&lt;strong>1. A token verifier that validates the signature and the audience.&lt;/strong> Against the realm&amp;rsquo;s key set, checking issuer, expiry and that the audience contains the MCP server&amp;rsquo;s canonical URL and &lt;strong>only&lt;/strong> things that concern it. Here the side effect of the Keycloak workaround comes in: if a client asks for two scopes from two different resources, out comes a token with two audiences, because each mapper contributes its own to the array. There is no client policy condition or executor in 26.7 that limits the number of audiences per token. The practical defence is the resource&amp;rsquo;s: reject tokens whose audience includes other resources, instead of merely checking that its own is present.&lt;/p>
&lt;p>&lt;strong>2. The complete metadata document&lt;/strong>, with all the authorisation servers you accept, the real catalogue of scopes the resource understands, and the name and documentation filled in. In Python, mounting the route by hand instead of leaving the automatic one. In TypeScript, mounting it, full stop.&lt;/p>
&lt;p>&lt;strong>3. The authentication challenge with the scope.&lt;/strong> In Python you have to add it, because the SDK does not emit it. And you have to take scope hierarchies into account, which is a new requirement of the revision.&lt;/p>
&lt;p>&lt;strong>4. The decision between local validation and introspection.&lt;/strong> Validating the signature locally costs no network, but &lt;strong>revocation does not take effect until the token expires&lt;/strong>: there are no revocation lists for signed tokens. Introspection costs a round trip per request and only confidential clients can invoke it. The reasonable combination is a short token life and introspection on the operations that change state.&lt;/p>
&lt;p>&lt;strong>5. Binding state to the user.&lt;/strong> The new revision is explicit about this, because with protocol sessions gone, state is carried in handlers that travel as an ordinary tool argument. Servers &lt;strong>must&lt;/strong> verify every incoming request and &lt;strong>must not&lt;/strong> treat possession of a handler as authentication; and they &lt;strong>should&lt;/strong> bind the handler to the user on the server side, for example storing the state under a key that combines the user identifier derived from the verified token with the handler, and reject the handler if someone else presents it. It is a change in the way of working compared with the sessions of before.&lt;/p>
&lt;h2 id="part-4-the-hop-from-the-gateway-to-the-mcp-server">Part 4. The hop from the gateway to the MCP server&lt;/h2>
&lt;p>The real shape in an inference platform is not client against MCP server: it is client against gateway, and gateway against MCP server. That second hop is where it is decided whether the architecture is correct.&lt;/p>
&lt;p>The specification leaves no room. The MCP server &lt;strong>must not&lt;/strong> accept or relay tokens that were not issued for it. And if it makes requests to upstream APIs, it may act as an OAuth client of theirs, but &lt;strong>the token it uses there is a different token, issued by the upstream authorisation server&lt;/strong>, and it must not forward the one it received.&lt;/p>
&lt;p>The standard mechanism for that is token exchange, and here you need to know three Keycloak details.&lt;/p>
&lt;p>&lt;strong>First, the permission model changed.&lt;/strong> The old version required fine-grained administration permissions and an explicit exchange authorisation on the target client. The standard version, supported since 26.2, does not require them: it is enough for the requesting client to be confidential and to have the corresponding switch enabled. In exchange there is a surprising condition: &lt;strong>the subject token has to carry the requesting client in its audience&lt;/strong>, unless it exchanges its own token. That is, the gateway needs to appear in the audience of the user token, which is achieved with an audience mapper in a default scope of the gateway. Only then can it narrow the audience to that of the specific MCP server.&lt;/p>
&lt;p>&lt;strong>Second, the audience is a client identifier, not a resource URL.&lt;/strong> The parameter filters audiences, that is to say it narrows them, which is what you want. But it takes the identifier of a client registered in the realm. The practical consequence is that &lt;strong>the MCP server has to be registered as a client, and its identifier should be its canonical URL&lt;/strong>, so that the resulting audience matches what the resource validates. It is a naming trick, and it has to be documented for whoever comes after. The documentation itself acknowledges it: token exchange does not yet support the resource parameter.&lt;/p>
&lt;p>&lt;strong>Third, the subject token type is limited.&lt;/strong> The standard version only accepts access tokens as subject.&lt;/p>
&lt;p>There is also a new experimental feature in 26.7 that adds a parameterised scope type for validating whether the requesting user is authorised to act on behalf of another. Interesting for delegation, but experimental.&lt;/p>
&lt;h3 id="what-the-gateway-does-today">What the gateway does today&lt;/h3>
&lt;p>In LiteLLM 1.102.0 the authentication modes towards MCP servers number twelve, with a default value of none. The ones that matter for this discussion are four.&lt;/p>
&lt;p>&lt;code>oauth2_token_exchange&lt;/code> implements the standard exchange. It sends the correct grant type, the subject token and its type, and &lt;strong>the audience, never the resource&lt;/strong>. The omission is deliberate according to the code itself: fabricating a target risks an invalid target error. It caches the resulting token under a key that combines the subject token with the whole configuration, and with a lifetime equal to the token&amp;rsquo;s minus one minute. And it &lt;strong>blocks, never degrades&lt;/strong>: with no incoming token it returns 401, which the edge turns into a challenge with the resource metadata URL; a rejection from the identity provider returns 401; a gateway configuration error returns 500; a transport failure, 503.&lt;/p>
&lt;p>&lt;code>true_passthrough&lt;/code> and &lt;code>oauth_delegate&lt;/code> forward the client&amp;rsquo;s authorisation header as is. That is token passthrough, by the name the specification gives it, and its legitimate use is narrow: when the token the client presents was already issued for the target MCP server and the gateway is a mere transport. Outside that case, it is non-compliant.&lt;/p>
&lt;p>&lt;code>oauth2_id_jag&lt;/code> is the surprise, and it deserves a part of its own.&lt;/p>
&lt;h2 id="part-5-the-enterprise-extension-which-changes-the-picture">Part 5. The enterprise extension, which changes the picture&lt;/h2>
&lt;p>In June 2026 the MCP project published an authorisation extension that solves a different problem from the one the standard flow solves, and which in an organisation with an identity provider of its own is the real problem.&lt;/p>
&lt;p>The normal flow is per user: each employee authorises each client against each MCP server. It works for consumer applications and it does not work in a company, because onboarding a person requires authorising dozens of services one by one and offboarding requires revoking them one by one.&lt;/p>
&lt;p>&lt;strong>The &lt;code>io.modelcontextprotocol/enterprise-managed-authorization&lt;/code> extension inverts that.&lt;/strong> It is in the stable specifications directory of the extensions repository, it comes from SEP-990, and the flow is this:&lt;/p>
&lt;ol>
&lt;li>The MCP client authenticates the user against the corporate identity provider through the normal flow, and &lt;strong>stores the identity assertion&lt;/strong>, which may be an OpenID identity token or a SAML assertion.&lt;/li>
&lt;li>When the server indicates that enterprise-managed authorisation is needed, the client &lt;strong>exchanges that assertion at the corporate provider for an authorisation grant based on an identity assertion&lt;/strong>. The provider evaluates the organisation&amp;rsquo;s policy there: group membership, roles, conditional access.&lt;/li>
&lt;li>The client presents that grant to the MCP authorisation server and obtains an access token.&lt;/li>
&lt;li>The sentence that defines the extension, verbatim: &lt;strong>the user is not redirected to the authorisation endpoint of the MCP authorisation server&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>The MCP authorisation server validates the signature against the corporate provider&amp;rsquo;s key set, plus audience, issuer and expiry, and uses the subject as the user&amp;rsquo;s stable identifier, with the email as a fallback for linking earlier accounts.&lt;/p>
&lt;p>What that buys is exactly what a compliance file asks for: policy in a single place, an auditable decision at the identity provider, and &lt;strong>centralised revocation that takes effect across all clients at once&lt;/strong>. The employee who loses access stops receiving grants, without touching any server.&lt;/p>
&lt;p>The underlying standard is a draft from the IETF&amp;rsquo;s OAuth working group, under the name of JWT identity assertion authorisation grant, revision 04 of May 2026, signed by people from Okta, Ping Identity and an independent author. It profiles identity chaining across trust domains by combining token exchange with the JWT profile.&lt;/p>
&lt;h3 id="and-here-comes-the-problem">And here comes the problem&lt;/h3>
&lt;p>&lt;strong>Keycloak implements it only halfway.&lt;/strong> It has its own documentation page, behind a feature flag. And three limitations you need in front of you before designing anything:&lt;/p>
&lt;ul>
&lt;li>It only acts as receiver. It accepts assertions issued by an external provider and issues local tokens. Native support for acting as issuer, the documentation says, is not yet fully implemented. In the extension&amp;rsquo;s flow, the issuer is the corporate provider: if that provider is Keycloak, the missing piece is exactly the one you need.&lt;/li>
&lt;li>It is based on draft 01 while the working group is on 04.&lt;/li>
&lt;li>The official documentation says not to use it in production.&lt;/li>
&lt;/ul>
&lt;p>The asymmetry is striking and worth pointing out: Keycloak does not support a standard published eight years ago such as resource indicators, and does partially support a draft from a few months back.&lt;/p>
&lt;h3 id="what-is-already-in-the-gateway-undocumented">What is already in the gateway, undocumented&lt;/h3>
&lt;p>In LiteLLM 1.102.0, the &lt;code>oauth2_id_jag&lt;/code> mode runs the two stages of the flow. In the first it asks the corporate provider for a token exchange with the requested token type specific to this grant, sending the user&amp;rsquo;s identity token as subject and, optionally, audience, &lt;strong>resource&lt;/strong> and scope. In the second it presents the resulting assertion to the resource&amp;rsquo;s authorisation server with the JWT bearer grant type. In both the gateway authenticates as a client with a JWT signed by private key.&lt;/p>
&lt;p>Two operational warnings that come out of the code and are nowhere else:&lt;/p>
&lt;ul>
&lt;li>The subject comes from the caller&amp;rsquo;s identity token, or from the one captured at login. And only the generic OIDC provider captures assertions: with Google, Microsoft or SAML there is none, and &lt;strong>all users fail&lt;/strong>. The code raises a warning when loading the configuration.&lt;/li>
&lt;li>A badly configured server refuses with a 500 instead of falling back to the static credential. It is the right decision, and it is worth knowing before it happens.&lt;/li>
&lt;/ul>
&lt;p>And the main warning: &lt;strong>there is no documentation&lt;/strong>. Not a single file in the repository that mentions this mode. Anyone using it is reading code, like this post.&lt;/p>
&lt;h2 id="part-6-per-tool-authorisation">Part 6. Per-tool authorisation&lt;/h2>
&lt;p>None of the previous pieces answers the question the business ends up asking: whether this person may call this tool with these arguments. The identity provider gives roles and scopes, and with that you do not model a graph of relations, as was already argued &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the previous post&lt;/a>.&lt;/p>
&lt;p>What is published today, with sources:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OpenFGA&lt;/strong> is the only one with a dedicated product guide, updated on 9 September 2026. The pattern is a tool type with an invocation relation, checked on each call, and a listing query so the client only sees the tools it may invoke. It is a pattern and a modelling language, not a reference implementation.&lt;/li>
&lt;li>&lt;strong>Pomerium&lt;/strong> ships a per-tool policy criterion, with exact, prefix, suffix and list matching.&lt;/li>
&lt;li>&lt;strong>Kong&lt;/strong> offers it as a product, with per-tool control lists.&lt;/li>
&lt;li>&lt;strong>Traefik Hub&lt;/strong> goes as far as parameter-level restrictions, which is the finest published grain.&lt;/li>
&lt;li>&lt;strong>Envoy&lt;/strong> has an MCP filter that extracts protocol attributes for fine-grained access control, declared to be in active development and with no OAuth of its own: the hook is external authorisation towards a policy engine.&lt;/li>
&lt;/ul>
&lt;p>The lowest-friction option in a platform that already has a service mesh is external authorisation against a policy engine. The most expressive one, the relations model.&lt;/p>
&lt;h2 id="part-7-what-still-has-no-standard">Part 7. What still has no standard&lt;/h2>
&lt;p>Two gaps the specification does not cover and that have to be closed from outside.&lt;/p>
&lt;p>&lt;strong>Tool definition change.&lt;/strong> Neither revision 2026-07-28 nor the SDKs pin a hash of the description or of the input schema. The best practices page covers confused deputy, token passthrough, server-side request forgery in discovery, state handler hijacking, server confusion and validation of the authorisation URL scheme, but &lt;strong>there is no integrity or pinning requirement&lt;/strong>. The tool that does it is the Invariant Labs scanner, under the Apache 2.0 licence, which pins hashes to detect definition change after approval and also ships a proxy mode with guardrails. It already came up &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a> and it remains the only answer.&lt;/p>
&lt;p>&lt;strong>The public guidance that does get into this&lt;/strong> is the NSA&amp;rsquo;s on MCP security design considerations, from May 2026, done with Carnegie Mellon&amp;rsquo;s software engineering institute. Two of its observations are directly actionable: that authorisation in MCP is optional and that the specification imposes no requirement on token lifecycle management, so expiry and rotation are left in the organisation&amp;rsquo;s hands; and the warning against dynamic tool discovery with no origin verification and no authorisation checks, which is the most direct acknowledgement of the risk in a state guidance document.&lt;/p>
&lt;h2 id="part-8-who-covers-what">Part 8. Who covers what&lt;/h2>
&lt;p>With all of the above, the decision table for the layer you put in front of the MCP server:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Publishes resource metadata&lt;/th>
&lt;th>Validates audience&lt;/th>
&lt;th>Token exchange&lt;/th>
&lt;th>Per-tool authorisation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Python SDK, automatic route&lt;/td>
&lt;td>Yes, degraded&lt;/td>
&lt;td>&lt;strong>Not by default&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TypeScript SDK&lt;/td>
&lt;td>Does not mount on its own&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Traefik Hub (commercial)&lt;/td>
&lt;td>Yes, automatic&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, down to parameter&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kong, OAuth plugin for MCP&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, per-tool lists&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pomerium&lt;/td>
&lt;td>Partial&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, per-tool criterion&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>agentgateway (Solo.io)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes, by JWT policy&lt;/td>
&lt;td>Yes, including the assertion grant&lt;/td>
&lt;td>Yes, with expressions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>mcp-context-forge (IBM)&lt;/td>
&lt;td>Not in release notes&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, since 1.0.6&lt;/td>
&lt;td>Yes, per-role control&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Envoy, MCP filter&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Via external authorisation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM 1.102.0&lt;/td>
&lt;td>Yes, and authorisation server metadata too&lt;/td>
&lt;td>&lt;strong>Not on the incoming JWT&lt;/strong>&lt;/td>
&lt;td>Yes, and the assertion grant&lt;/td>
&lt;td>Yes, per-key and per-team permissions&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two warnings about that table. The Kong plugin requires a minimum version of 3.12, is in technical preview and needs a licence for its AI edition; it also introduces a breaking change in 3.13, which starts treating all traffic as MCP in order to close a possible authentication bypass. And on oauth2-proxy I found no primary documentation either for or against protected resource mode: I am not claiming it does not have it, I am claiming it is not documented where I could look.&lt;/p>
&lt;h2 id="reference-configuration">Reference configuration&lt;/h2>
&lt;p>&lt;strong>Keycloak: the per-resource scope, optional, with its audience.&lt;/strong> That it is optional and not default is the key piece, because it is what makes the client have to ask for it explicitly and makes it work as a substitute for the resource indicator:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kcadm.sh create client-scopes -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>mcp:inventario -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;attributes.&amp;#34;include.in.token.scope&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create &lt;span class="s2">&amp;#34;client-scopes/&amp;lt;id&amp;gt;/protocol-mappers/models&amp;#34;&lt;/span> -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>aud-mcp-inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocolMapper&lt;/span>&lt;span class="o">=&lt;/span>oidc-audience-mapper &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;included.custom.audience&amp;#34;=https://mcp.ejemplo.es/inventario&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;access.token.claim&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the MCP server registered as a client &lt;strong>with its canonical URL as identifier&lt;/strong>, so that token exchange produces the right audience:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kcadm.sh create clients -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">clientId&lt;/span>&lt;span class="o">=&lt;/span>https://mcp.ejemplo.es/inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">enabled&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> -s &lt;span class="nv">publicClient&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">false&lt;/span> -s &lt;span class="nv">consentRequired&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>The gateway, with token exchange instead of token passthrough:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">mcp_servers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">inventario&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">transport&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">auth_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;oauth2_token_exchange&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">client_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;litellm-gateway&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">client_secret&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/GW_SECRET&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">token_exchange_endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://sso.ejemplo.es/realms/plataforma/protocol/openid-connect/token&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">audience&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">subject_token_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;urn:ietf:params:oauth:token-type:access_token&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">token_exchange_profile&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;rfc8693&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>The resource verifier, which is what nobody hands you ready-made.&lt;/strong> The essential bit is the last check, the one that rejects other resources&amp;rsquo; audiences instead of settling for finding its own:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">RECURSO&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">verificar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">AccessToken&lt;/span> &lt;span class="o">|&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">claims&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">jwt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">jwks&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">algorithms&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;RS256&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">audience&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># validates that I am there&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">issuer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ISSUER&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">aud&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;aud&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">aud&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">aud&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># and that nobody else is: the scopes workaround allows&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># tokens with two audiences, and that reopens the confused deputy&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">CLIENTE_GATEWAY&lt;/span>&lt;span class="p">}:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">AccessToken&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">client_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;azp&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">scopes&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;scope&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">split&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">expires_at&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;exp&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resource&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And when mounting the server, the flag that does not come set:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">auth&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AuthSettings&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">issuer_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ISSUER&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resource_server_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">validate_token_resource&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># without this there is only a warning&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">required_scopes&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;mcp:inventario&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Enable audience validation in the Python SDK, or write it into the verifier if it is TypeScript. Without that, the central normative requirement is not met.&lt;/li>
&lt;li>Reject tokens with other resources&amp;rsquo; audiences, not just check that your own is present.&lt;/li>
&lt;li>Mount the metadata document by hand, with all the authorisation servers and the real catalogue of scopes.&lt;/li>
&lt;li>Add the scope parameter to the challenge, in Python, and account for scope hierarchies.&lt;/li>
&lt;li>Register each MCP server as a Keycloak client with its canonical URL as identifier, so that token exchange produces the right audience.&lt;/li>
&lt;li>Replace any token passthrough mode with token exchange, except the narrow case where the token was already issued for the target.&lt;/li>
&lt;li>Check that the gateway appears in the audience of the user token, or the exchange will fail.&lt;/li>
&lt;li>Bind state handlers to the user on the server side, and never treat them as authentication.&lt;/li>
&lt;li>Decide between local validation and introspection knowing that the first delays revocation until expiry, and shorten the token life accordingly.&lt;/li>
&lt;li>Pin a hash of the description and the input schema from outside, because the specification does not cover it.&lt;/li>
&lt;li>Put a policy engine in for per-tool authorisation, and do not try to model it with roles.&lt;/li>
&lt;li>If you are considering the enterprise extension, check first who issues the grant: Keycloak today only knows how to receive it, in experimental and against an earlier draft.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>The Python SDK mounts the metadata document on its own, and that is why it looks solved.&lt;/strong> What it publishes carries a single authorisation server and no name and no documentation.&lt;/p>
&lt;p>&lt;strong>The audience validation deprecation warning does not enable anything.&lt;/strong> It warns and carries on behaving as disabled.&lt;/p>
&lt;p>&lt;strong>The TypeScript SDK does not validate the audience at all.&lt;/strong> It is not configuration, it is that there is no code for it.&lt;/p>
&lt;p>&lt;strong>Python&amp;rsquo;s challenge does not carry the scope&lt;/strong>, so a client that receives a 403 does not know what to ask for.&lt;/p>
&lt;p>&lt;strong>The scopes workaround allows two audiences in the same token.&lt;/strong> Each mapper contributes its own, and there is no client policy that limits it. The defence is in the resource.&lt;/p>
&lt;p>&lt;strong>The token exchange audience is a client identifier, not a URL.&lt;/strong> If the MCP server is not registered with its URL as identifier, the resulting audience will not match what the resource validates.&lt;/p>
&lt;p>&lt;strong>Token exchange requires the gateway to be in the audience of the user token.&lt;/strong> It is the condition that wastes the most time the first time round.&lt;/p>
&lt;p>&lt;strong>Dynamic client registration is deprecated&lt;/strong> as of this revision, and the substitute, client identifier metadata documents, is experimental in Keycloak and has an open bug with documents carrying unknown fields, which blocks login with real clients.&lt;/p>
&lt;p>&lt;strong>LiteLLM&amp;rsquo;s identity assertion grant depends on the generic OIDC provider.&lt;/strong> With Google, Microsoft or SAML no assertion is captured and all users fail.&lt;/p>
&lt;p>&lt;strong>Step-up elevation is no longer the server&amp;rsquo;s responsibility.&lt;/strong> Scope accumulation moved to the client in this revision.&lt;/p>
&lt;p>&lt;strong>The authentication elevation signal goes in a 401, not a 403.&lt;/strong> Insufficient scope is 403; asking for a higher authentication level is a different thing and a different code.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The summary of this post fits in one sentence: the identity provider is the cheap half of the problem, and the expensive half is the protected resource, which no SDK hands you ready-made and which in the two official ones comes with audience validation disabled or absent.&lt;/p>
&lt;p>From that comes an order of work. First the token verifier, with the audience validated in both directions, because without it everything else is decoration. Second, the complete metadata document, mounted by hand. Third, replacing any token passthrough with exchange, with the MCP server registered with its URL as identifier. Fourth, a policy engine for what roles do not model.&lt;/p>
&lt;p>And a scheduling decision worth taking with the information in front of you. The enterprise-managed authorisation extension is the right route for an organisation with a provider of its own, it is stable and it has servers in production behind it. What is not ready is Keycloak as issuer. Anyone who wants that flow today has two honest options: delegate the mechanics to a gateway that implements it, or wait. Putting it into production with a feature the official documentation marks as experimental and expressly advises against is not one of them.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>: the identity piece from the inside, and the gap this post closes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up: giving it authentication with Keycloak&lt;/a>: the basic setup, predating this revision of the specification.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second front door, its permissions, its cost and its lack of audit logging.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: the audience validation that does not happen on the gateway side either.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">The model&amp;rsquo;s chain of trust (4 of 4)&lt;/a>: workload identity with attestation, the other plane.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its observability&lt;/a>: the protocol and its primitives.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents&lt;/a>: the network isolation everything above presupposes.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Especificación MCP, revisión 2026-07-28: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization">autorización&lt;/a>, &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration">registro de clientes&lt;/a> y &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">buenas prácticas de seguridad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization">Extensión de autorización gestionada por la empresa&lt;/a> y su &lt;a href="https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx">especificación estable&lt;/a>, del SEP-990. Anuncio de 18 de junio de 2026.&lt;/li>
&lt;li>&lt;a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/">draft-ietf-oauth-identity-assertion-authz-grant&lt;/a>, revisión 04 de 21 de mayo de 2026, grupo de trabajo OAuth del IETF.&lt;/li>
&lt;li>Código de los SDK oficiales de MCP: &lt;code>python-sdk&lt;/code> y &lt;code>typescript-sdk&lt;/code> 2.0.0-alfa, clonados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Integración de Keycloak con Model Context Protocol&lt;/a>, &lt;a href="https://www.keycloak.org/securing-apps/token-exchange">intercambio de tokens&lt;/a> y &lt;a href="https://www.keycloak.org/securing-apps/identity-assertion-jwt-authorization-grant">concesión de autorización por aserción de identidad&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>Incidencias de Keycloak &lt;a href="https://github.com/keycloak/keycloak/issues/14355">14355&lt;/a>, &lt;a href="https://github.com/keycloak/keycloak/issues/47117">47117&lt;/a> y &lt;a href="https://github.com/keycloak/keycloak/issues/51039">51039&lt;/a>, y petición de cambios &lt;a href="https://github.com/keycloak/keycloak/pull/35711">35711&lt;/a>.&lt;/li>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF">NSA y SEI, Model Context Protocol: Security Design Considerations&lt;/a>, mayo de 2026.&lt;/li>
&lt;li>&lt;a href="https://openfga.dev/docs/use-cases/mcp-server-authorization">OpenFGA, autorización de servidores MCP&lt;/a>, actualizado el 9 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://invariantlabs-ai.github.io/docs/mcp-scan/">mcp-scan de Invariant Labs&lt;/a>, Apache 2.0.&lt;/li>
&lt;li>Documentación de &lt;a href="https://docs.solo.io/agentgateway/2.3.x/mcp/auth/setup/">agentgateway&lt;/a>, &lt;a href="https://doc.traefik.io/traefik-hub/mcp-gateway/mcp">Traefik Hub&lt;/a>, &lt;a href="https://developer.konghq.com/plugins/ai-mcp-oauth2/">Kong&lt;/a> y &lt;a href="https://www.pomerium.com/docs/capabilities/mcp">Pomerium&lt;/a>, consultadas el 12 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item><item><title>Keycloak in an AI platform: where it fits, what it solves and the two standards MCP requires and it does not implement</title><link>https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/</link><pubDate>Sat, 12 Sep 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/</guid><description>&lt;blockquote>
&lt;p>This post opens the identity block of the operations track. &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The previous one&lt;/a> covered the gateway side: the JWT that does not validate audience, the user who never reaches the trace. This one covers the piece on the other side. Verified against Keycloak 26.7.3, revision 2026-07-28 of the MCP specification and the text of Royal Decree 311/2022 in the BOE.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Keycloak 26.7.3 was released on 31 August 2026&lt;/strong> and is still incubating at the CNCF, which it entered in April 2023. The cadence is four minor releases a year and support in practice covers a single branch: the twenty security bugs fixed in 26.7.3 were not backported to the 26.4, 26.5 or 26.6 branches at the time of release.&lt;/p>
&lt;p>&lt;strong>The Quarkus-based distribution separates build and runtime configuration.&lt;/strong> What is set in the build phase is frozen into the image, and changing it at startup forces a rebuild. In containers, the pattern is to build into the image and start with the optimised flag.&lt;/p>
&lt;p>&lt;strong>Storage is relational and only relational.&lt;/strong> The alternative engine that was in development was withdrawn in version 25.0. Since 26.0 sessions are persisted to the database by default, which changes the load profile: the dominant cost in the official benchmarks is database CPU.&lt;/p>
&lt;p>&lt;strong>Organisations solves multi-tenancy inside a realm&lt;/strong>, fully supported since 26.0. It groups members, associates email domains and links identity providers per organisation. It is the alternative to replicating realms per customer.&lt;/p>
&lt;p>&lt;strong>The measured bottleneck is password hashing.&lt;/strong> Since 25.0 the default algorithm outside FIPS environments is Argon2, with around 7 MB of memory per operation. The official sizing guide gives one vCPU for every 15 password logins per second, against 120 per second for client credentials and for refreshes.&lt;/p>
&lt;p>&lt;strong>Keycloak does not implement two standards the MCP specification marks as mandatory.&lt;/strong> Protected resource metadata belongs to the MCP server, not the authorisation server, so there is nothing to hold against it there. Resource indicators do belong to it and are &lt;strong>not supported&lt;/strong>. Keycloak&amp;rsquo;s own documentation declares partial conformance with the last three MCP revisions for that reason. The practical substitute is scopes plus an audience mapper.&lt;/p>
&lt;p>&lt;strong>There is a recent critical bug worth looking at today.&lt;/strong> &lt;code>CVE-2026-18963&lt;/code>, scored 9.1, allowed resetting any user&amp;rsquo;s password without authenticating, administrators included. Fixed in 26.7.2, released on 19 August 2026.&lt;/p>
&lt;h2 id="you-are-here-the-cross-cutting-layer">You are here: the cross-cutting layer&lt;/h2>
&lt;p>Identity is not a stage of the pipeline, it is a layer that crosses all of them. It shows up in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">stack hardening&lt;/a> as one of the seven layers of defence, in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">the model&amp;rsquo;s chain of trust&lt;/a> as workload identity, in &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">the gateway&lt;/a> as token validation, and in &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">the post about authenticating MCP servers&lt;/a> as authorisation server.&lt;/p>
&lt;p>What was missing was the post about the piece itself, and above all the decision of where to put it and what to expect from it.&lt;/p>
&lt;h2 id="the-analogy-the-civil-registry-and-building-permits">The analogy: the civil registry and building permits&lt;/h2>
&lt;p>A small town hall has a population register, which says who lives there, and it has permits, which say who can do what. They are two different things and it is worth not confusing them, because the register is good for the whole town while a permit is good for one specific job on one specific street.&lt;/p>
&lt;p>Keycloak is the register. It knows who each person is, identifies them reliably, knows which groups they belong to and issues a credential document that expires. What it does not do is decide whether this person can modify this particular file: that is the permit, and it lives in each service.&lt;/p>
&lt;p>Confusing the two produces the most expensive design mistake in platforms of this kind, which consists of putting into the token everything that will need to be decided later. The register document grows, gets handed out everywhere, and when a permission changes you have to wait for everyone&amp;rsquo;s document to expire.&lt;/p>
&lt;p>There is a third element the analogy captures well. A credential document issued for presenting at the tax office should not be good for entering the municipal depot. That is the token&amp;rsquo;s audience, and it is the central subject of the second half of this post.&lt;/p>
&lt;h2 id="part-1-what-keycloak-is-today">Part 1. What Keycloak is today&lt;/h2>
&lt;h3 id="project-status">Project status&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Item&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Latest stable version&lt;/td>
&lt;td>26.7.3, released on 31 August 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Previous ones&lt;/td>
&lt;td>26.7.2 (19 August), 26.7.0 (9 July), 26.6.0 (8 April)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cadence&lt;/td>
&lt;td>four minor releases a year&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CNCF&lt;/td>
&lt;td>incubating since 10 April 2023, not graduated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Support&lt;/td>
&lt;td>the current branch; earlier ones do not receive fixes reliably&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That last point deserves underlining because it conditions the upgrade policy. The analysis of 26.7.3 shows that its twenty security fixes were not backported to earlier branches at release time. Operating Keycloak means keeping up to date with the current branch, not staying on a &amp;ldquo;stable&amp;rdquo; version for a year.&lt;/p>
&lt;p>Since 26.0 there is a backward compatibility guarantee across minor versions for features marked as fully supported, and breaking changes are opt-in.&lt;/p>
&lt;h3 id="the-internal-architecture-in-five-pieces">The internal architecture in five pieces&lt;/h3>
&lt;p>&lt;strong>Two startup modes.&lt;/strong> The development one enables HTTP, does not resolve the hostname strictly and disables the theme cache. The production one requires a hostname and TLS, or it will not start. Confusing them in production is the first antipattern on the final list.&lt;/p>
&lt;p>&lt;strong>The build phase.&lt;/strong> The build command materialises the optimisations into the image. Build options, such as the database engine, the enabled features or metrics, are frozen. Runtime ones, such as the database password or the hostname, are applied at startup. Changing a build option at startup forces a rebuild with the corresponding latency cost. The correct pattern in containers is to build into the image and start with the optimised flag.&lt;/p>
&lt;p>&lt;strong>Extensions via SPI.&lt;/strong> Authenticators, protocol mappers, user storage, event listeners, hashing algorithms and secret vaults. They are packaged as JARs and resolved in the build phase.&lt;/p>
&lt;p>&lt;strong>Infinispan cache.&lt;/strong> Embedded by default, with database-based discovery as the recommended mechanism. The discovery alternatives via Kubernetes DNS, TCP and UDP are deprecated. There are local caches, with default capacities of 10,000 entries in several of them, and replicated caches for sessions, client sessions, offline sessions, authentication sessions, login failures and action tokens. Remote mode, with an external Infinispan, is needed for first-generation multi-site deployment.&lt;/p>
&lt;p>&lt;strong>Relational storage.&lt;/strong> JPA over a database. The alternative engine was removed in version 25.0. The databases supported today include PostgreSQL from 14 to 18, MariaDB and MySQL in their extended support versions, SQL Server 2019 and 2022, Oracle 19c and 23.x, and the managed variants Aurora PostgreSQL and Azure SQL.&lt;/p>
&lt;h3 id="the-minimum-conceptual-model">The minimum conceptual model&lt;/h3>
&lt;p>Before touching anything you need to be clear on eight concepts, and the order in which they relate:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Realm&lt;/strong>: the isolation boundary. Its own users, its own signing keys, its own policies. Two realms share nothing.&lt;/li>
&lt;li>&lt;strong>Client&lt;/strong>: an application that asks for tokens. Public if it cannot keep a secret, such as a browser application; confidential if it can, such as a service; and bearer-only if it merely validates tokens without requesting them.&lt;/li>
&lt;li>&lt;strong>Client scope&lt;/strong>: a reusable bundle of scopes and mappers, assigned to clients as default or as optional. It is the unit with which you bound what a token carries.&lt;/li>
&lt;li>&lt;strong>Protocol mapper&lt;/strong>: what projects an attribute, a role or an audience into the token. The audience mapper is the one that solves the central problem of this post.&lt;/li>
&lt;li>&lt;strong>Role&lt;/strong>, realm or client, and &lt;strong>composite role&lt;/strong>, which aggregates others.&lt;/li>
&lt;li>&lt;strong>Group&lt;/strong>, hierarchical, with inherited roles and attributes.&lt;/li>
&lt;li>&lt;strong>Identity provider&lt;/strong>: federation towards another external OIDC or SAML. &lt;strong>User federation&lt;/strong>: LDAP, Active Directory and Kerberos, with synchronisation and attribute mapping.&lt;/li>
&lt;li>&lt;strong>Authentication flow&lt;/strong>: a sequence of executions with required, alternative, conditional or disabled requirements, and subflows. It is where you build a second factor or a check of your own.&lt;/li>
&lt;/ul>
&lt;p>And one feature that changes architectures: &lt;strong>organisations&lt;/strong>, fully supported since 26.0. It solves multi-tenancy inside a single realm, grouping members, associating email domains and linking identity providers per organisation, so that provider discovery is done by the user&amp;rsquo;s email domain. The classic alternative, one realm per customer, multiplies administration and shares no configuration.&lt;/p>
&lt;h2 id="part-2-who-can-authenticate-against-it">Part 2. Who can authenticate against it&lt;/h2>
&lt;p>This is the table you need before drawing anything. The right-hand column is usually the one that decides.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Protocol&lt;/th>
&lt;th>Cost&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Kubernetes API&lt;/td>
&lt;td>native OIDC, or the structured authentication configuration file&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM&lt;/td>
&lt;td>OIDC and JWT, plus UI SSO&lt;/td>
&lt;td>&lt;strong>JWT and SSO above five users: Enterprise&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>vLLM&lt;/td>
&lt;td>static key only&lt;/td>
&lt;td>No OIDC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&lt;/td>
&lt;td>generic OIDC and Keycloak provider&lt;/td>
&lt;td>SSO free; per-project access control: Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Grafana&lt;/td>
&lt;td>generic OAuth &lt;strong>yes in the open edition&lt;/strong>&lt;/td>
&lt;td>Team synchronisation: Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Backstage&lt;/td>
&lt;td>generic OIDC and community-maintained Keycloak provider&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Open WebUI&lt;/td>
&lt;td>OIDC, with role management by claim&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LibreChat&lt;/td>
&lt;td>generic OIDC, with Keycloak documented&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Argo CD&lt;/td>
&lt;td>native OIDC or Dex, with groups into its RBAC file&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MinIO&lt;/td>
&lt;td>OIDC with role assumption via web identity&lt;/td>
&lt;td>See the note below&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two clarifications that change decisions.&lt;/p>
&lt;p>&lt;strong>Kubernetes.&lt;/strong> The modern mechanism is structured authentication configuration, which allows several JWT issuers plus validations and mappings with CEL expressions. The corresponding proposal, KEP-3331, went alpha in 1.29, beta in 1.30 and is &lt;strong>stable since 1.34&lt;/strong>. It is the route to use today, and it replaces the loose API server flags.&lt;/p>
&lt;p>&lt;strong>vLLM has no identity.&lt;/strong> All there is is a static key via environment variable. This is not a defect to fix, it is an architectural fact: the engine sits behind the gateway and its access control is network-based, not identity-based. Any design that exposes vLLM directly to users is giving up per-person traceability.&lt;/p>
&lt;p>About MinIO a note of honesty is in order: the public documentation now redirects to the commercial edition, so the detail of how it maps policies from a claim is verified there and not in the open edition. Anyone depending on that mechanism should confirm it against their own version before designing on top of it.&lt;/p>
&lt;h2 id="part-3-humans-and-machines">Part 3. Humans and machines&lt;/h2>
&lt;p>They are two different problems and it is worth solving them separately.&lt;/p>
&lt;h3 id="people">People&lt;/h3>
&lt;p>Authorization code with PKCE, with the S256 method. The MCP specification requires it when the client is technically capable, and it goes further: it mandates &lt;strong>rejecting the flow&lt;/strong> if the authorisation server metadata does not publish the supported challenge methods.&lt;/p>
&lt;h3 id="workloads-with-no-person-behind-them">Workloads with no person behind them&lt;/h3>
&lt;p>Here the obvious option is client credentials with a Keycloak service account, authenticated with a secret or, better, with a JWT signed by private key.&lt;/p>
&lt;p>But there is a less known and better option for workloads inside the cluster: &lt;strong>federated client authentication&lt;/strong>, introduced in January 2026 under the banner of doing away with secrets. It lets a client authenticate with a token issued by an external provider, and one of those providers is Kubernetes. The announcement placed full support for the Kubernetes service account variant and for OIDC in version 26.6; the SPIFFE variant, by contrast, expressly says it will stay in preview until the SPIFFE client authentication standard is finalised at the IETF.&lt;/p>
&lt;p>That means a pod can authenticate against Keycloak with the token the kubelet already projects into it, with no additional secret to rotate. It is worth confirming the exact status and the flag name against the release notes of whichever version you have deployed, because the feature is recent and has changed status between 26.5 and 26.6. It is preferable to token exchange, whose standard version is supported but is internal-to-internal only, while the old one, which covered external-to-internal and impersonation, is deprecated and in preview.&lt;/p>
&lt;h3 id="keycloak-against-spiffe">Keycloak against SPIFFE&lt;/h3>
&lt;p>The question always comes up and the answer is that they solve different things with a small overlap.&lt;/p>
&lt;p>SPIRE attests the node and the process, and issues short-lived identity documents with no prior secret. Keycloak does not attest workloads: it trusts a credential someone had to place there, except in the federated authentication case just mentioned, where the one attesting is Kubernetes. In exchange, Keycloak federates people, issues tokens with business data and governs consent, things SPIFFE does not do.&lt;/p>
&lt;p>The practical rule: SPIFFE for service-to-service identity inside the perimeter, Keycloak for anything that has a person behind it or crosses the perimeter. The SPIFFE detail is in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">the close of the chain of trust series&lt;/a>.&lt;/p>
&lt;h2 id="part-4-mcp-and-the-two-missing-standards">Part 4. MCP, and the two missing standards&lt;/h2>
&lt;p>This part has the most consequences and is the worst documented outside the primary sources.&lt;/p>
&lt;h3 id="what-the-specification-requires">What the specification requires&lt;/h3>
&lt;p>From revision 2026-07-28, with the normative words as they appear:&lt;/p>
&lt;ul>
&lt;li>The MCP server &lt;strong>must&lt;/strong> implement RFC 9728 protected resource metadata, and the client &lt;strong>must&lt;/strong> use it to discover the authorisation server.&lt;/li>
&lt;li>The client &lt;strong>must&lt;/strong> implement RFC 8707 resource indicators, including the &lt;code>resource&lt;/code> parameter &lt;strong>in both the authorisation request and the token request&lt;/strong>, and &lt;strong>must&lt;/strong> send it regardless of whether the authorisation server supports it.&lt;/li>
&lt;li>The MCP server &lt;strong>must&lt;/strong> validate that the access token was issued specifically for it as the audience, and &lt;strong>must not&lt;/strong> accept or relay any other token.&lt;/li>
&lt;li>Authorisation servers and clients &lt;strong>should&lt;/strong> support client identifier metadata documents. RFC 7591 dynamic registration becomes optional and is declared deprecated, retained for compatibility.&lt;/li>
&lt;/ul>
&lt;p>The revision also adds the issuer in the authorisation response, from RFC 9207, and a stateless model with state handlers where it is expressly stated that holding a state handler is not equivalent to being authenticated.&lt;/p>
&lt;h3 id="what-keycloak-supports">What Keycloak supports&lt;/h3>
&lt;p>From its own MCP integration guide:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Standard&lt;/th>
&lt;th>Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>OAuth 2.1 and authorisation server metadata&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RFC 9207, issuer in the response&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RFC 7591, dynamic registration&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Client identifier metadata document&lt;/td>
&lt;td>Supported, &lt;strong>experimental&lt;/strong>, after enabling the feature&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RFC 8707, resource indicators&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Not supported&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RFC 9728, protected resource metadata&lt;/strong>&lt;/td>
&lt;td>Out of scope, it belongs to the MCP server&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The conformance declared by the project itself is &amp;ldquo;partially supported, without resource indicators&amp;rdquo; for the last three MCP revisions.&lt;/p>
&lt;p>Two practical conclusions:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The protected resource metadata document is published by the MCP server or a proxy in front of it&lt;/strong>, never by Keycloak. That is correct and there is nothing to fix, but you have to remember to implement it, because the client needs it to discover where to go.&lt;/li>
&lt;li>&lt;strong>Without resource indicators, the audience is bounded with scopes.&lt;/strong> The official route is to use the &lt;code>scope&lt;/code> parameter instead of &lt;code>resource&lt;/code>, and to hang from that client scope an audience mapper with the MCP server URL as the included custom audience.&lt;/li>
&lt;/ol>
&lt;p>It is a workaround, it works, and it has to be documented in the design because anyone coming after will look for the standard parameter and will not find it.&lt;/p>
&lt;h2 id="part-5-the-confused-deputy">Part 5. The confused deputy&lt;/h2>
&lt;p>With the above on the table, the classic mistake becomes obvious on its own. A token with a generic audience turns any compromised service into a master key: whoever obtains the token a user presented to the dashboard can present it to the gateway, and from there to the MCP server.&lt;/p>
&lt;p>The MCP specification is explicit in three sentences worth quoting verbatim in any architecture document:&lt;/p>
&lt;ul>
&lt;li>MCP servers &lt;strong>must not&lt;/strong> accept tokens that were not explicitly issued for them.&lt;/li>
&lt;li>If the MCP server calls upstream APIs, it &lt;strong>must not&lt;/strong> forward the token it received from the client.&lt;/li>
&lt;li>MCP proxies that use static client identifiers &lt;strong>must&lt;/strong> obtain user consent for each dynamically registered client before forwarding to third-party authorisation servers.&lt;/li>
&lt;/ul>
&lt;p>Translated into the specific figure this track has been dealing with: &lt;strong>the gateway must not forward the user&amp;rsquo;s JWT to third-party MCP servers&lt;/strong>. It has to obtain a different token with the right audience, or act as an OAuth client in its own right. By default, as already documented &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a>, the behaviour is to use the server&amp;rsquo;s own credential, which is the correct one; there are two modes that forward the caller&amp;rsquo;s token and both have a name in the specification.&lt;/p>
&lt;h2 id="part-6-two-hours-of-task-against-five-minutes-of-token">Part 6. Two hours of task against five minutes of token&lt;/h2>
&lt;p>The specification pushes towards short-lived tokens and mandatory refresh token rotation in public clients. A two-hour agentic task comfortably exceeds the life of an access token and may also exceed session idle time if nobody refreshes.&lt;/p>
&lt;p>Three real options, with their cost:&lt;/p>
&lt;p>&lt;strong>Offline tokens.&lt;/strong> They survive session expiry, and expire through their own idle timeout or through a maximum if that limit is enabled. The risk is obvious: a near-permanent credential, hard to inventory. And the MCP specification adds that MCP servers &lt;strong>should not&lt;/strong> include that scope either in the authentication header or in the supported scopes of their metadata.&lt;/p>
&lt;p>&lt;strong>Automatic refresh in the client.&lt;/strong> It is the right answer when there is a client that can implement it, with refresh token rotation and secure storage. For an agent with no person behind it, it is better to renew via client credentials.&lt;/p>
&lt;p>&lt;strong>Long-lived virtual keys in the gateway.&lt;/strong> It works, and it is what most people end up doing. The price has to be written down in the risk analysis: identity ends up decoupled from the provider, so deactivating someone in Keycloak does not invalidate their key, and traceability back to the real person depends on the discipline with which the keys were created. The levers that compensate are expiry, automatic rotation and a mandatory alias, covered &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">in the virtual keys post&lt;/a>.&lt;/p>
&lt;p>A note of honesty about the default values. The numbers going around (five minutes of access token, thirty of session idle time, ten hours of maximum) do not appear published in the administration guide and I did not locate them in the code. Treat them as a console observation and confirm them in your own realm before quoting them in a document.&lt;/p>
&lt;h2 id="part-7-sizing-and-operating">Part 7. Sizing and operating&lt;/h2>
&lt;h3 id="on-kubernetes">On Kubernetes&lt;/h3>
&lt;p>The operator exposes three custom resources: the server, the realm import and the OIDC client. Probes and metrics live on &lt;strong>management port 9000&lt;/strong>, with the health and readiness paths, and metrics in Prometheus format provided the corresponding option was enabled in the build phase. That port is not exposed publicly.&lt;/p>
&lt;p>For high availability there are three shapes. A single cluster spread across zones, with no additional infrastructure. First-generation multi-cluster, with an external load balancer and an external Infinispan at each site. And second-generation multi-cluster, introduced in 26.7.0 and still in preview, which connects clusters &lt;strong>without external Infinispan&lt;/strong>, using the synchronously replicated database as the source of truth, with less cache and more database load.&lt;/p>
&lt;h3 id="the-official-sizing-figures">The official sizing figures&lt;/h3>
&lt;p>From the CPU and memory sizing concepts guide, which is still published:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>1 vCPU for every 15 password logins per second&lt;/strong>, tested up to 300 per second.&lt;/li>
&lt;li>&lt;strong>1 vCPU for every 120 client credential grants per second&lt;/strong>, tested up to 2,000.&lt;/li>
&lt;li>&lt;strong>1 vCPU for every 120 token refresh requests per second&lt;/strong>, tested up to 435.&lt;/li>
&lt;li>Leave a &lt;strong>150 % CPU headroom&lt;/strong> for spikes, startup and failover.&lt;/li>
&lt;li>&lt;strong>1,250 MB of memory per pod&lt;/strong> with realm data and 10,000 cached sessions. Keycloak allocates 70 % of the limit to the heap and around 300 MB outside it, so the limit comes from subtracting those 300 MB from the expected usage and dividing by 0.7.&lt;/li>
&lt;/ul>
&lt;p>The stated assumptions behind those figures include Argon2 with five iterations, sessions in the database and the default cache of 10,000 entries.&lt;/p>
&lt;p>The difference between 15 and 120 is the whole story of Keycloak&amp;rsquo;s performance: &lt;strong>the cost is in password hashing&lt;/strong>. Since 25.0, the default algorithm outside FIPS environments is Argon2, with around 7 MB of memory per operation. Before it was PBKDF2, whose iterations were multiplied by ten in version 24. In FIPS mode PBKDF2 is still used.&lt;/p>
&lt;p>From that comes an architectural consequence for an AI platform: &lt;strong>machine workloads, which go through client credentials, cost eight times less than a human login&lt;/strong>. Sizing by number of services is cheap; sizing by morning login peaks is the expensive part.&lt;/p>
&lt;h3 id="the-benchmark">The benchmark&lt;/h3>
&lt;p>From the 26.4 performance report, published on 1 October 2025: up to 2,000 logins per second and 10,000 refreshes per second with three pods, 74 vCPU in total and 8 GB per pod, over an Aurora PostgreSQL database, with almost linear vertical scaling.&lt;/p>
&lt;p>Two useful findings from that report. With 20 ms of round-trip time between sites, the 99th percentile rises from 51 to 130 ms, and the project &lt;strong>advises against deploying across different regions&lt;/strong>. And raising the cache from 10,000 to 200,000 entries brought the database CPU peak down from 77.77 % to 63.77 %, in exchange for somewhat longer garbage collection pauses.&lt;/p>
&lt;h3 id="security">Security&lt;/h3>
&lt;p>The urgent matter, if the deployment is below 26.7.2:&lt;/p>
&lt;p>&lt;strong>&lt;code>CVE-2026-18963&lt;/code>, score 9.1, critical.&lt;/strong> A bypass of the email action token in the credential reset flow allowed resetting any user&amp;rsquo;s password with no prior authentication, administrators included. Fixed in 26.7.2, released on 19 August 2026. The temporary mitigation, if you cannot upgrade, is to disable password recovery in all realms. No known exploitation was recorded as of the end of August.&lt;/p>
&lt;p>In 26.7.3 there are another nineteen, among them the missing hostname verification in the TLS certificate towards LDAP with a score of 8.8, two tenant restriction bypasses in token exchange with Microsoft and Google brokers, and two access control bypasses in organisations.&lt;/p>
&lt;p>Official hardening comes down to four decisions:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Strict hostname&lt;/strong>, which is the default value since the second generation of that configuration. The server does not infer the URL from headers, which prevents poisoning email links and redirects.&lt;/li>
&lt;li>&lt;strong>Administration console on a different hostname&lt;/strong>, and network-restricted.&lt;/li>
&lt;li>&lt;strong>Proxy headers declared explicitly&lt;/strong>, trusting only a controlled reverse proxy.&lt;/li>
&lt;li>&lt;strong>Separate management port, not exposed.&lt;/strong>&lt;/li>
&lt;/ol>
&lt;h2 id="part-8-the-mapping-to-the-ens">Part 8. The mapping to the ENS&lt;/h2>
&lt;p>The codes and titles below are verified against the official text of Royal Decree 311/2022 published in the BOE, Annex II.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Code&lt;/th>
&lt;th>Literal title&lt;/th>
&lt;th>What a central IdP contributes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>op.acc.1&lt;/td>
&lt;td>Identificación&lt;/td>
&lt;td>A unique identifier per entity, user or process that gains access&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.2&lt;/td>
&lt;td>Requisitos de acceso&lt;/td>
&lt;td>Permissions are granted by role and group, not by loose credential&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.3&lt;/td>
&lt;td>Segregación de funciones y tareas&lt;/td>
&lt;td>It supports it with roles, but the control is organisational&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.4&lt;/td>
&lt;td>Proceso de gestión de derechos de acceso&lt;/td>
&lt;td>Least privilege and a specific remote access policy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.5&lt;/td>
&lt;td>Mecanismo de autenticación (usuarios externos)&lt;/td>
&lt;td>Flows and factors configurable per flow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.6&lt;/td>
&lt;td>Mecanismo de autenticación (usuarios de la organización)&lt;/td>
&lt;td>Reinforcements required already at the low level&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.exp.8&lt;/td>
&lt;td>Registro de la actividad&lt;/td>
&lt;td>It contributes part of the events, not all of them&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.exp.10&lt;/td>
&lt;td>Protección de claves criptográficas&lt;/td>
&lt;td>Management and rotation of the realm signing keys&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two nuances an auditor is going to look for.&lt;/p>
&lt;p>The first, about &lt;strong>op.acc.1&lt;/strong>: the text requires every entity that accesses the system, whether user or process, to have a unique identifier. That includes agents. A virtual key shared between several agents does not comply, and that is exactly what usually happens when JWT authentication falls outside the budget.&lt;/p>
&lt;p>The second, about &lt;strong>op.exp.8&lt;/strong>: the control requires recording the identifier of the user or entity associated with the event, the date and time, what information it is performed on, the type and the result. Keycloak contributes the authentication events. The usage events are contributed by the gateway and the MCP servers, and there the two already documented gaps reappear: &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">adding and removing MCP servers generates no audit log&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">the trace user is set by the client&lt;/a>.&lt;/p>
&lt;p>The correspondence between control and product in this table is my own interpretation and must be validated by the certification body. The codes and titles are not: those are literal from the BOE.&lt;/p>
&lt;h2 id="part-9-the-configuration-to-leave-written-down">Part 9. The configuration to leave written down&lt;/h2>
&lt;p>Three concrete pieces, so as not to stay at the level of concepts.&lt;/p>
&lt;p>&lt;strong>Hardened startup&lt;/strong>, as container arguments:&lt;/p>
&lt;pre tabindex="0">&lt;code>kc.sh start --optimized \
--hostname=https://sso.ejemplo.es \
--hostname-admin=https://sso-admin.interna.ejemplo.es \
--proxy-headers=xforwarded \
--health-enabled=true \
--metrics-enabled=true \
--http-management-port=9000 \
--cache=ispn --cache-stack=jdbc-ping \
--db=postgres
&lt;/code>&lt;/pre>&lt;p>The metrics, health, cache and database options are build options: they go into the image, and passing them here forces a rebuild if they do not match.&lt;/p>
&lt;p>&lt;strong>The client scope with audience&lt;/strong>, which is the workaround forced by the lack of resource indicators. Created with the administration tool:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># one scope per protected resource&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create client-scopes -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>mcp-inventario -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;attributes.&amp;#34;include.in.token.scope&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># the mapper that sets the token audience&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create &lt;span class="s2">&amp;#34;client-scopes/&amp;lt;id&amp;gt;/protocol-mappers/models&amp;#34;&lt;/span> -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>aud-mcp-inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocolMapper&lt;/span>&lt;span class="o">=&lt;/span>oidc-audience-mapper &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;included.custom.audience&amp;#34;=https://mcp.ejemplo.es/inventario&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;access.token.claim&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The client asks for that scope and receives a token whose audience is only that MCP server. The MCP server validates the audience and rejects any other token, as the specification requires.&lt;/p>
&lt;p>&lt;strong>Realm import via GitOps&lt;/strong>, with the operator&amp;rsquo;s custom resource, so that the configuration does not live only in the console:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">k8s.keycloak.org/v2beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">KeycloakRealmImport&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plataforma&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">identidad&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">keycloakCRName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sso&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">realm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">realm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plataforma&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, the recovery email is attack surface&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resetPasswordAllowed&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bruteForceProtected&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sslRequired&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">all&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="part-10-what-keycloak-does-not-solve">Part 10. What Keycloak does not solve&lt;/h2>
&lt;p>&lt;strong>Fine-grained authorisation over resources.&lt;/strong> The question &amp;ldquo;can this person see this particular trace&amp;rdquo; is a relation between objects, not a role. That problem is solved by Zanzibar-style engines, with OpenFGA and its object, relation and user tuples, or a policy language such as Cedar. Keycloak gives roles and scopes, and with those you do not model a graph of relations.&lt;/p>
&lt;p>&lt;strong>Machine secrets.&lt;/strong> Storage credentials, provider keys, database passwords. That is a secrets manager, covered in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">stack hardening&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Cryptographic workload identity with attestation.&lt;/strong> That is SPIFFE and SPIRE. In Keycloak it exists as an identity provider in preview, which is a different thing.&lt;/p>
&lt;p>&lt;strong>Authorisation inside the model.&lt;/strong> Which MCP tool an agent may invoke, and with what arguments. The specification offers scopes, the insufficient scope error and step-up elevation, but the per-tool and per-argument decision is taken by the MCP server or a policy engine, not by the identity provider.&lt;/p>
&lt;h2 id="antipatterns">Antipatterns&lt;/h2>
&lt;ol>
&lt;li>Forwarding the user&amp;rsquo;s token from the gateway to third-party MCP servers. Explicitly forbidden by the specification.&lt;/li>
&lt;li>Static client identifier with dynamic registration and no per-client consent, which is the exact recipe for the confused deputy.&lt;/li>
&lt;li>General scopes, of the full-access kind, and publishing the entire catalogue of supported scopes in the metadata.&lt;/li>
&lt;li>Treating a state handler as authentication, which the new revision expressly forbids.&lt;/li>
&lt;li>Following OAuth discovery towards any URL, which opens the door to requests towards internal addresses. It also applies to the authorisation server when it downloads a client metadata document.&lt;/li>
&lt;li>Trust headers badly exposed in chat front ends. The Open WebUI documentation warns that an incorrect configuration allows authenticating as any user.&lt;/li>
&lt;li>Starting in development mode in production, with the hostname not resolved strictly.&lt;/li>
&lt;li>A single shared static key as the only control between the gateway and the engine.&lt;/li>
&lt;/ol>
&lt;h2 id="startup-checklist">Startup checklist&lt;/h2>
&lt;ol>
&lt;li>Check the version. Below 26.7.2 there is a critical password reset bug.&lt;/li>
&lt;li>Build the image with the build phase done and start with the optimised flag.&lt;/li>
&lt;li>Strict hostname, administration console on another hostname, separate management port and not exposed.&lt;/li>
&lt;li>Decide between organisations and one realm per tenant before creating the second tenant.&lt;/li>
&lt;li>One client scope per resource, with an audience mapper, because resource indicators are not supported.&lt;/li>
&lt;li>In the gateway, define the two audience and issuer variables. Without them none of that is validated.&lt;/li>
&lt;li>For workloads inside the cluster, look at the Kubernetes identity provider before handing out client secrets.&lt;/li>
&lt;li>Size by login peaks, not by number of services: the difference is eightfold.&lt;/li>
&lt;li>Export the Keycloak events to the same place the gateway&amp;rsquo;s go, because the activity logging control needs them together.&lt;/li>
&lt;li>Write into the design what the system does with a two-hour task, before someone solves the problem with an offline token.&lt;/li>
&lt;/ol>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>Keycloak is a mature piece that solves one bounded problem well: knowing who the caller is, and saying so verifiably in a document that expires. In a sovereign inference platform that is worth a lot, because the alternative is an inventory of static keys spread across ten services that no audit can walk through.&lt;/p>
&lt;p>What is not advisable is asking more of it. Fine-grained authorisation lives in each service, workload identity with attestation lives in SPIFFE, and the decision of which tool an agent may call lives in the MCP server.&lt;/p>
&lt;p>And there is one concrete gap worth noting down, because it does not close on its own: the MCP specification marks two standards as mandatory, and of the two that would fall to an authorisation server, resource indicators are not implemented. The workaround with scopes and an audience mapper works, but it is a workaround, and whoever comes after will look for the standard parameter. Leaving it written in the design saves an afternoon.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: how the gateway validates the tokens this piece issues, and the two variables you have to define.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up: giving it authentication with Keycloak&lt;/a>: the concrete setup for MCP servers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second front door, its permissions and its lack of audit logging.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">The model&amp;rsquo;s chain of trust (4 of 4)&lt;/a>: SPIFFE, SPIRE and workload identity with attestation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack&lt;/a>: where identity fits among the seven layers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a>: the alternative when JWT falls outside the budget.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS, ISO 42001 and the EU AI Act&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a>: the follow-up: the protected resource side you have to build, token exchange and the enterprise authorisation extension.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.keycloak.org/2026/08/keycloak-2673-released">Keycloak 26.7.3&lt;/a>, 31 de agosto de 2026, y &lt;a href="https://www.keycloak.org/downloads">descargas&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/server/configuration">Configuración del servidor&lt;/a>, &lt;a href="https://www.keycloak.org/server/caching">caché&lt;/a>, &lt;a href="https://www.keycloak.org/server/db">bases de datos&lt;/a> y &lt;a href="https://www.keycloak.org/server/hostname">nombre de host&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Integración con Model Context Protocol&lt;/a>, &lt;a href="https://www.keycloak.org/securing-apps/token-exchange">intercambio de tokens&lt;/a> y &lt;a href="https://www.keycloak.org/2026/01/federated-client-authentication">autenticación federada de clientes&lt;/a>, 27 de enero de 2026.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/high-availability/multi-cluster/concepts-memory-and-cpu-sizing">Dimensionado de CPU y memoria&lt;/a> e &lt;a href="https://www.keycloak.org/high-availability/introduction">introducción a alta disponibilidad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/2025/10/keycloak-benchmark">Bancos de pruebas de rendimiento de Keycloak 26.4&lt;/a>, 1 de octubre de 2025.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization">Especificación de MCP, revisión 2026-07-28, autorización&lt;/a> y &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">buenas prácticas de seguridad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-auth/3331-structured-authentication-configuration">KEP-3331, configuración estructurada de autenticación&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.boe.es/diario_boe/xml.php?id=BOE-A-2022-7191">Real Decreto 311/2022, Anexo II&lt;/a>.&lt;/li>
&lt;li>Registro de &lt;code>CVE-2026-18963&lt;/code> en la base de datos de Red Hat y notas de la versión 26.7.3 en GitHub.&lt;/li>
&lt;li>&lt;a href="https://www.cncf.io/projects/keycloak/">CNCF, proyecto Keycloak&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Langfuse v4, day 2 (1 of 10): the data model changed underneath, and what really goes into a trace</title><link>https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/</link><pubDate>Sat, 12 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/</guid><description>&lt;blockquote>
&lt;p>First article in a series of ten about operating Langfuse v4 in production. The architecture of version 3 and its ten performance levers were already covered in &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a>; the seam with the gateway, in &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the operational pair&lt;/a>. This series is about day 2. Verified against Langfuse 4.35.0, commit of 11 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Version 4 is observations first.&lt;/strong> There is no trace table. A trace is a set of observations that share an identifier, and the trace attributes live denormalised in every row: name, user, session, tags, version, release and environment (&lt;code>clickhouse/migrations/canonical/0039_create_events_full.up.sql&lt;/code>). The whole intuition of &amp;ldquo;first I create the trace and then I hang things off it&amp;rdquo; stops describing the storage.&lt;/p>
&lt;p>&lt;strong>There are ten observation types, not three.&lt;/strong> Alongside the classic &lt;code>SPAN&lt;/code>, &lt;code>EVENT&lt;/code> and &lt;code>GENERATION&lt;/code> come &lt;code>AGENT&lt;/code>, &lt;code>TOOL&lt;/code>, &lt;code>CHAIN&lt;/code>, &lt;code>RETRIEVER&lt;/code>, &lt;code>EVALUATOR&lt;/code>, &lt;code>EMBEDDING&lt;/code> and &lt;code>GUARDRAIL&lt;/code> (&lt;code>packages/shared/src/domain/observations.ts:5&lt;/code>). Using the right type changes what the interface knows how to group.&lt;/p>
&lt;p>&lt;strong>The real limits are in the code and they do not match what people assume.&lt;/strong> The body of a request to the classic ingestion route is capped at &lt;strong>4.5 MB&lt;/strong> (&lt;code>api/public/ingestion.ts:40&lt;/code>). The OTLP body, at &lt;strong>512 MiB&lt;/strong>, compressed and decompressed, with a 413 when it is exceeded. An OTLP span above 9.5 MB is only written to the log, not rejected. Identifiers accept 800 characters. The trace name, 1,000.&lt;/p>
&lt;p>&lt;strong>The ingester looks for the user in five places and the session in six.&lt;/strong> With strict precedence order, starting from Langfuse&amp;rsquo;s own attributes and ending in the metadata of other frameworks. Among the session ones sits &lt;code>gen_ai.conversation.id&lt;/code>, which is the OpenTelemetry standard, so a conformant instrumentation works without translation.&lt;/p>
&lt;p>&lt;strong>Scores do not travel over OTLP.&lt;/strong> Zero occurrences of the word across the 3,864 lines of the OTLP ingestion processor. They come in through the classic ingestion route, through the scores endpoint or through the interface. Any design that assumes an evaluator can write its score down the same channel as the trace is wrong.&lt;/p>
&lt;p>&lt;strong>The classic masking function does not cover what almost everyone thinks it covers.&lt;/strong> It only acts on data that passes through the SDK calls. The attributes produced by a third-party automatic instrumentation come out unmasked. There is a new function that does act on the batch attributes, but it leaves out span events, which is where the recent conventions put the messages.&lt;/p>
&lt;p>&lt;strong>Filtering by tags or by status message is a scan.&lt;/strong> In the listings table, the indexed fields are the span identifier, the trace identifier, the user identifier, the session identifier, the dates, the model and the metadata names. The name, the tags, the version and the status message have no index.&lt;/p>
&lt;h2 id="you-are-here-observe-day-2">You are here: OBSERVE, day 2&lt;/h2>
&lt;p>The previous series about this pair covered selection and setup. The &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">six-service architecture&lt;/a> explained version 3; &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">tracing with OpenTelemetry&lt;/a> explained the pipeline; the &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">pair with the gateway&lt;/a> explained where the trace gets lost between the two pieces.&lt;/p>
&lt;p>What is left uncovered is the work of the next three years: migrating, sizing ClickHouse, retaining and deleting, taking backups, alerting, getting the data out. This series is about that, and it starts with the question that conditions everything else, which is what gets stored.&lt;/p>
&lt;h2 id="the-analogy-the-archive-that-changed-system">The analogy: the archive that changed system&lt;/h2>
&lt;p>An archive with a folder per case file and documents inside each folder is easy to understand. You open the folder, you look at what is there. The problem shows up when you have to find every document of one kind over the last two years: you have to open every folder.&lt;/p>
&lt;p>Large archives solve that the other way around. There is no folder: each document carries, copied into its header, the case reference, the client name and the date. Searching by client is immediate. Reconstructing a case file is grouping by reference. In exchange, each document weighs a little more, and what gets written into that header matters a great deal, because everything you can search for comes from there.&lt;/p>
&lt;p>Langfuse v4 made exactly that change. What used to be a folder with attributes of its own now travels repeated in the header of every document. The two operational consequences of this article come from there: which fields are worth filling in and which fields are not worth filling with content.&lt;/p>
&lt;h2 id="the-data-model-as-it-is-in-the-code">The data model, as it is in the code&lt;/h2>
&lt;h3 id="the-tables">The tables&lt;/h3>
&lt;p>Three new pieces in ClickHouse:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>events_full&lt;/code>&lt;/strong> (migration 0039): the table of record, with the full input and output, compressed with ZSTD level 3 and indexed with full-text indexes over the lowercase versions. &lt;code>ReplacingMergeTree&lt;/code> engine ordered by event timestamp and deletion flag, partitioned by month of the start time.&lt;/li>
&lt;li>&lt;strong>&lt;code>events_core&lt;/code>&lt;/strong> (migration 0040): the listings table, lighter.&lt;/li>
&lt;li>&lt;strong>&lt;code>events_core_mv&lt;/code>&lt;/strong> (migration 0041): the materialised view that fills the previous one, and which &lt;strong>truncates to two hundred characters&lt;/strong> the input, the output and each metadata value.&lt;/li>
&lt;/ul>
&lt;p>That truncation to two hundred characters is the first thing to know. What you see in a listing is not what is stored; it is a clipping. And when reading a full trace a second cap applies on top, &lt;code>LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT&lt;/code>, with a default value of a thousand (&lt;code>packages/shared/src/env.ts:513&lt;/code>).&lt;/p>
&lt;p>In the &lt;code>events_full&lt;/code> table there is a configuration line that says a lot: &lt;code>index_granularity_bytes = '64Mi'&lt;/code>, with the comment that it avoids very small granules because of large rows. It is the schema itself acknowledging that the rows of this system are bulky.&lt;/p>
&lt;h3 id="the-ten-observation-types">The ten observation types&lt;/h3>
&lt;p>&lt;code>SPAN&lt;/code>, &lt;code>EVENT&lt;/code>, &lt;code>GENERATION&lt;/code>, &lt;code>AGENT&lt;/code>, &lt;code>TOOL&lt;/code>, &lt;code>CHAIN&lt;/code>, &lt;code>RETRIEVER&lt;/code>, &lt;code>EVALUATOR&lt;/code>, &lt;code>EMBEDDING&lt;/code> and &lt;code>GUARDRAIL&lt;/code> (&lt;code>domain/observations.ts:5&lt;/code>), with their corresponding ingestion events (&lt;code>server/ingestion/types.ts:279&lt;/code>).&lt;/p>
&lt;p>For a platform with agents, the difference between marking a tool call as a generic &lt;code>SPAN&lt;/code> or as a &lt;code>TOOL&lt;/code> is the difference between being able to count tool calls per session and having to reconstruct it by hand. It is worth reviewing the instrumentation with this list in front of you.&lt;/p>
&lt;p>The level is still &lt;code>DEBUG&lt;/code>, &lt;code>DEFAULT&lt;/code>, &lt;code>WARNING&lt;/code> and &lt;code>ERROR&lt;/code> (&lt;code>domain/observations.ts:31&lt;/code>), with a free-form status message.&lt;/p>
&lt;h3 id="the-limits-with-their-source">The limits, with their source&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>Limit&lt;/th>
&lt;th>File&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Trace, span and observation identifiers&lt;/td>
&lt;td>800 characters, no carriage return&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:10&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Trace name&lt;/td>
&lt;td>1,000 characters&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:428&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Environment&lt;/td>
&lt;td>40 characters, pattern of lowercase, digits, hyphen and underscore&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:226&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Text-type score&lt;/td>
&lt;td>500 characters&lt;/td>
&lt;td>&lt;code>domain/scores.ts:44&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Score config name&lt;/td>
&lt;td>35 characters&lt;/td>
&lt;td>&lt;code>domain/score-configs.ts:11&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Classic ingestion body&lt;/td>
&lt;td>4.5 MB per request&lt;/td>
&lt;td>&lt;code>api/public/ingestion.ts:40&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTLP body&lt;/td>
&lt;td>512 MiB, adjustable&lt;/td>
&lt;td>&lt;code>web/src/env.mjs:583&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTLP span considered large&lt;/td>
&lt;td>9.5 MB, only logged&lt;/td>
&lt;td>&lt;code>packages/shared/src/env.ts:193&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Name, status message, version, release, tags&lt;/td>
&lt;td>&lt;strong>no declared limit&lt;/strong>&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:424&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Metadata&lt;/td>
&lt;td>&lt;strong>no declared limit&lt;/strong>&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:425&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last two are the dangerous ones, because they have no schema cap and the real ceiling ends up being the one on the request body. A three-megabyte metadata value passes validation and reaches ClickHouse.&lt;/p>
&lt;p>A note on the &lt;code>langfuse&lt;/code> prefix in the environment field: it is reserved for the product&amp;rsquo;s own internal traces and is stripped idempotently from public inputs (&lt;code>ingestion/types.ts:228&lt;/code>), including the case of someone trying to sneak it in duplicated. The default value is &lt;code>default&lt;/code>.&lt;/p>
&lt;p>On rate limits: in the cloud there are per-plan quotas, but &lt;strong>in self-hosted they are not applied&lt;/strong>. The service returns directly that there is no limiting (&lt;code>features/public-api/server/RateLimitService.ts&lt;/code>). It is good news for bulk exports and bad news for anyone relying on that mechanism as protection.&lt;/p>
&lt;h2 id="the-two-instrumentation-routes">The two instrumentation routes&lt;/h2>
&lt;h3 id="what-gets-lost-over-otlp-and-it-is-one-specific-thing">What gets lost over OTLP, and it is one specific thing&lt;/h3>
&lt;p>The check is simple and the result is blunt: &lt;strong>there is not a single occurrence of the word &lt;code>score&lt;/code> in the OTLP ingestion processor&lt;/strong>, a file of 3,864 lines. Scores only come in through three places: the &lt;code>score-create&lt;/code> event of the classic ingestion route, the &lt;code>POST /api/public/scores&lt;/code> endpoint, or the interface.&lt;/p>
&lt;p>This has a design consequence worth being clear about before building evaluators: the trace can travel through the OpenTelemetry collector with everything good that brings, on-disk queue, retries and sampling, but the score an evaluator puts on it has to go down a different channel and with the project keys.&lt;/p>
&lt;p>Everything else does travel over OTLP. Whatever is not in the mapping lists is kept as metadata converted to a string, visible but outside the indexed fields.&lt;/p>
&lt;h3 id="the-precedence-chain-which-is-the-map-you-need-to-have">The precedence chain, which is the map you need to have&lt;/h3>
&lt;p>Verified in &lt;code>packages/shared/src/server/otel/OtelIngestionProcessor.ts&lt;/code>:&lt;/p>
&lt;p>&lt;strong>User identifier&lt;/strong> (line 2545), in this order: &lt;code>langfuse.user.id&lt;/code>, &lt;code>user.id&lt;/code>, the observation metadata, the trace metadata, and lastly the Vercel SDK metadata.&lt;/p>
&lt;p>&lt;strong>Session identifier&lt;/strong> (line 2565): &lt;code>langfuse.session.id&lt;/code>, &lt;code>session.id&lt;/code>, &lt;strong>&lt;code>gen_ai.conversation.id&lt;/code>&lt;/strong>, observation metadata, trace metadata, Vercel metadata.&lt;/p>
&lt;p>&lt;strong>Environment&lt;/strong> (line 2383): &lt;code>langfuse.environment&lt;/code>, &lt;code>deployment.environment.name&lt;/code>, &lt;code>deployment.environment&lt;/code>; for each key the span attributes are checked first and the resource attributes after.&lt;/p>
&lt;p>&lt;strong>Model&lt;/strong> (line 2753): &lt;code>langfuse.observation.model.name&lt;/code>, &lt;code>gen_ai.response.model&lt;/code>, &lt;code>ai.model.id&lt;/code>, &lt;code>gen_ai.request.model&lt;/code>, &lt;code>llm.response.model&lt;/code>, &lt;code>llm.model_name&lt;/code>, &lt;code>model&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Level&lt;/strong> (line 1250): the native attribute, with accepted aliases where &lt;code>SUCCESS&lt;/code> and &lt;code>OK&lt;/code> become &lt;code>DEFAULT&lt;/code>, &lt;code>WARN&lt;/code> becomes &lt;code>WARNING&lt;/code> and &lt;code>FATAL&lt;/code> or &lt;code>CRITICAL&lt;/code> become &lt;code>ERROR&lt;/code>; if there is no attribute and the span status is an error, the result is &lt;code>ERROR&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Tags&lt;/strong> (line 3227): &lt;code>langfuse.trace.tags&lt;/code>, &lt;code>langfuse.tags&lt;/code>, observation and trace metadata, Vercel metadata, and &lt;code>tag.tags&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Input and output&lt;/strong> (line 1790): a chain of some twenty frameworks in strict order, starting from Langfuse&amp;rsquo;s own attributes and going through Genkit, the Vercel SDK, the standard inference operation details event of the conventions from 1.37 onwards, the per-role message events, the prompt and completion attributes, Logfire, MLflow, TraceLoop, OpenInference and the &lt;code>gen_ai.input.messages&lt;/code> and &lt;code>gen_ai.output.messages&lt;/code> attributes.&lt;/p>
&lt;p>That this chain has twenty entries says something about the real state of the conventions: it was &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">already noted&lt;/a> that the generative AI conventions were extracted into their own repository without any published version. While that remains the case, the backend has to guess, and it guesses with a precedence list.&lt;/p>
&lt;p>The practical part: &lt;strong>if Langfuse&amp;rsquo;s own attributes are emitted, they always win&lt;/strong>. If you want backend-neutral instrumentation, &lt;code>gen_ai.conversation.id&lt;/code> and &lt;code>deployment.environment.name&lt;/code> are the two standard attributes that Langfuse does understand.&lt;/p>
&lt;h3 id="metadata-and-how-it-ends-up-in-the-table">Metadata, and how it ends up in the table&lt;/h3>
&lt;p>A metadata value is flattened into two parallel arrays, one of names and one of values, with dot-separated paths and everything converted to a string (&lt;code>otel/utils.ts:239&lt;/code>, applied in &lt;code>worker/src/services/IngestionService/index.ts:363&lt;/code>). A nested object with twenty keys is twenty entries in each array.&lt;/p>
&lt;p>There is a cap of 10,001 positions when reconstructing arrays from OTel attributes (&lt;code>OtelIngestionProcessor.ts:271&lt;/code>), which is high but it exists.&lt;/p>
&lt;h2 id="sessions-users-and-cardinality">Sessions, users and cardinality&lt;/h2>
&lt;p>A session is a text column with a Bloom filter index at 1 %. The same goes for the user. The Postgres table that accompanies sessions stores only whether it is bookmarked, whether it is public and its environment; there is no counter and no cap. &lt;strong>There is no limit on traces per session in the code&lt;/strong>, and the documentation recommends session identifiers below 200 ASCII characters.&lt;/p>
&lt;p>The cardinality difference matters and it is not intuitive: user and session are plain text, so high cardinality does not break the schema, it only inflates the Bloom filters. &lt;strong>The environment, by contrast, is declared low cardinality.&lt;/strong> Generating a dynamic environment value per tenant degrades ClickHouse measurably.&lt;/p>
&lt;p>Aggregated cost comes out of a cost details map with materialised input, output and total columns. The classic sessions endpoint is deprecated; the replacement is the version 2 observations one with a filter.&lt;/p>
&lt;h2 id="scores-five-types-and-three-sources">Scores: five types and three sources&lt;/h2>
&lt;p>The types are &lt;code>NUMERIC&lt;/code>, &lt;code>CATEGORICAL&lt;/code>, &lt;code>BOOLEAN&lt;/code>, &lt;code>CORRECTION&lt;/code> and &lt;code>TEXT&lt;/code> (&lt;code>domain/scores.ts:45&lt;/code>). The boolean requires a value of zero or one. The correction does not accept an associated config. The text one is capped at 500 characters.&lt;/p>
&lt;p>The sources are &lt;code>API&lt;/code>, &lt;code>EVAL&lt;/code> and &lt;code>ANNOTATION&lt;/code>. The &lt;code>EVAL&lt;/code> source is &lt;strong>reserved for the internal evaluators&lt;/strong>: the public API only accepts the other two. Annotation scores require an associated config except for the correction one.&lt;/p>
&lt;p>On what costs money, a clarification that corrects a widespread belief: in 4.35.0 the entitlement for the number of model-based evaluators is unlimited &lt;strong>on every plan&lt;/strong>, including the open edition (&lt;code>features/entitlements/constants/entitlements.ts:60&lt;/code>). What is limited by plan is the number of annotation queues and the days of data access. And there is a version nuance: trace-level evaluators are discontinued in version 4.&lt;/p>
&lt;h2 id="masking-and-what-it-does-not-cover">Masking, and what it does not cover&lt;/h2>
&lt;p>This is the part to read twice if the deployment has to comply with anything.&lt;/p>
&lt;p>The classic Python SDK function has the signature &lt;code>masking_function(*, data, **kwargs)&lt;/code> and covers &lt;strong>only&lt;/strong> the data that passes through the SDK calls: observation creation, update, and setting the trace input and output. &lt;strong>It does not cover the raw OTel attributes&lt;/strong> produced by a third-party automatic instrumentation.&lt;/p>
&lt;p>The replacement is a function that acts on the attributes and the resource attributes in the export batch. That one does reach automatic instrumentation, with a limitation declared in the documentation: it cannot change the span name, the identifiers, the parent relationship, the resource attributes, &lt;strong>the events&lt;/strong> or the links.&lt;/p>
&lt;p>And there is the gap that matters: the generative AI conventions from 1.37 onwards put messages and responses into &lt;strong>span events&lt;/strong>. That is, the route by which prompts arrive in a modern instrumentation is precisely the one the new masking does not touch.&lt;/p>
&lt;p>For a deployment with personal data, the conclusion is that client-side masking is not enough and a redaction processor has to go into the collector. It is the same reasoning that led &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">in the previous article&lt;/a> to recommending the collector in the middle, and it adds to the already documented problem of MCP tool arguments being written in clear, bypassing the message redaction switch.&lt;/p>
&lt;p>On the server side there is no safety net: &lt;strong>Langfuse has no server-side personal data masking&lt;/strong>. The encryption key that gets configured protects secrets, that is, model provider credentials, integration headers and SSO configurations. &lt;strong>Trace inputs and outputs are not encrypted at application level.&lt;/strong>&lt;/p>
&lt;h2 id="volume-control">Volume control&lt;/h2>
&lt;p>Three levers, in order of how high up they act:&lt;/p>
&lt;p>&lt;strong>In the SDK&lt;/strong>: &lt;code>sample_rate&lt;/code>, or the &lt;code>LANGFUSE_SAMPLE_RATE&lt;/code> variable, with head sampling at trace level. If the trace is not sampled, neither its observations nor its scores travel. &lt;code>LANGFUSE_TRACING_ENABLED&lt;/code> set to false switches the whole thing off.&lt;/p>
&lt;p>&lt;strong>In the collector&lt;/strong>: probabilistic sampling and tail sampling. It is the only layer where you can decide based on the outcome, that is, keep every error and a fraction of the rest.&lt;/p>
&lt;p>&lt;strong>In the server&lt;/strong>: a deterministic per-project sampling, with a SHA-256 hash of the trace identifier (&lt;code>server/ingestion/sampling.ts&lt;/code>). Deterministic per trace, so it does not cut traces in half.&lt;/p>
&lt;h2 id="what-not-to-put-into-a-trace">What not to put into a trace&lt;/h2>
&lt;p>With the schema in front of you, the list is concrete.&lt;/p>
&lt;p>&lt;strong>Do not filter by tags or by status message.&lt;/strong> In the listings table the indexed fields are the span identifier, the trace identifier, the user identifier, the session identifier, the creation and update dates, the model name, the experiment identifier and the metadata names. The name, the tags, the version, the release and the status message are not. Filtering by them scans.&lt;/p>
&lt;p>&lt;strong>Do not use metadata as a store.&lt;/strong> It gets flattened into string arrays, truncated to two hundred characters in the listings table but stored whole in the full table, and on top of that it is indexed with a full-text index segmented on non-alphabetic characters. Bulky metadata multiplies the inverted index, not just the data.&lt;/p>
&lt;p>&lt;strong>Do not store full prompts without thinking about it.&lt;/strong> The input and the output carry ZSTD level 3 compression and two full-text indexes over their lowercase versions. Storing the entire context of a 142,000-token agentic call on every turn inflates the inverted index disproportionately. Client-side truncation is the cheap lever.&lt;/p>
&lt;p>&lt;strong>Do not use the environment to separate tenants.&lt;/strong> It is low cardinality, it has a 40-character cap and it isolates nothing: same project, same keys, same limits, same retention. It is there to segment dashboards. Real isolation means separate projects, as was already argued &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">in the article on the seams&lt;/a>.&lt;/p>
&lt;h2 id="version-3-and-the-calendar-date">Version 3 and the calendar date&lt;/h2>
&lt;p>The constant is in the code: &lt;code>V3_SUNSET_DATE = &amp;quot;2026-11-16&amp;quot;&lt;/code> (&lt;code>features/public-api/server/deprecations.ts:11&lt;/code>).&lt;/p>
&lt;p>It is worth being precise about what it means, because the public documentation and the code do not say exactly the same thing. The code comment is literal: the classic ingestion route &lt;strong>is never switched off&lt;/strong> and continues to accept score events; trace and observation events fail only in version 4&amp;rsquo;s exclusive write mode, not in the mixed or classic modes. And when they fail, they fail &lt;strong>per event&lt;/strong>, not per batch.&lt;/p>
&lt;p>The &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header activates direct writing to the events table, which is the real-time route. Without it the deferred path is taken. Recent SDKs send it by themselves. Any value above four is rejected.&lt;/p>
&lt;p>As for names, trace-level input and output attributes are deprecated in favour of the observation ones on the root span. The classic read endpoints carry a deprecation marker in the response body, and their replacements are version 2 of observations, version 3 of scores and version 2 of metrics. And dataset runs are renamed experiments.&lt;/p>
&lt;p>There is a curious piece that deserves a mention because it shows up in the Redis dashboards and is misleading: a queue called version 4 classic API usage, which &lt;strong>ingests nothing&lt;/strong>. It is a periodic task every fifteen minutes that scans the ClickHouse query log to detect which projects are still using the old API, and materialises the result into Redis for the interface&amp;rsquo;s migration notice.&lt;/p>
&lt;h2 id="how-this-looks-in-code">How this looks in code&lt;/h2>
&lt;p>Two pieces, the client one and the collector one.&lt;/p>
&lt;p>On the application side, what changes compared with the usual instrumentation is the observation type and where the identity comes from:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">get_client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">lf&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">get_client&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># identity propagates by context, not observation by observation&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">id_conversacion&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># the same across every turn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">sujeto_del_token&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># from the claim, not from the body&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">environment&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;produccion&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;soporte&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;n2&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># the type matters: the interface groups by it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">lf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">start_as_current_observation&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">as_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;agent&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;resolver-incidencia&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">raiz&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">lf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">start_as_current_observation&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">as_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;tool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;buscar_en_inventario&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">herramienta&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resultado&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">mcp&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">call&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;buscar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">args&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># truncate here, not on the server&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">herramienta&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">update&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resultado&lt;/span>&lt;span class="p">)[:&lt;/span>&lt;span class="mi">4000&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># the score goes down its own channel, never over OTLP&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">lf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">score_current_trace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;resuelta&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">data_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;BOOLEAN&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>as_type&lt;/code> parameter accepts nine literals: &lt;code>span&lt;/code>, &lt;code>generation&lt;/code>, &lt;code>embedding&lt;/code>, &lt;code>agent&lt;/code>, &lt;code>tool&lt;/code>, &lt;code>chain&lt;/code>, &lt;code>retriever&lt;/code>, &lt;code>evaluator&lt;/code> and &lt;code>guardrail&lt;/code>. The tenth type in the model, the event, has its own constructor because it does not open a scope.&lt;/p>
&lt;p>On the collector side, the redaction processor that compensates for what the SDK masking does not reach:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">redaction&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allow_all_keys&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">blocked_values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;\b\d{8}[A-HJ-NP-TV-Z]\b&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># national ID number&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;\b[\w.+-]+@[\w-]+\.[\w.]+\b&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># email&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">silent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tail_sampling&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">errores&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">status_code&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">status_code&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">status_codes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">ERROR]}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">resto&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">probabilistic&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">probabilistic&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">sampling_percentage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">otlphttp/langfuse&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://langfuse-web.observabilidad.svc:3000/api/public/otel&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">headers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">Authorization&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Basic ${LANGFUSE_BASIC}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">x-langfuse-ingestion-version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sending_queue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">storage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">file_storage&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retry_on_failure&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The endpoint carries the base path without the signal suffix: the exporter adds its own. And the on-disk queue is the main reason for putting the collector in the middle, because the SDK&amp;rsquo;s batch processor drops when it fills up.&lt;/p>
&lt;h2 id="instrumentation-checklist">Instrumentation checklist&lt;/h2>
&lt;ol>
&lt;li>Review the type of every observation against the list of ten. Tool calls as &lt;code>TOOL&lt;/code>, agents as &lt;code>AGENT&lt;/code>, guardrails as &lt;code>GUARDRAIL&lt;/code>.&lt;/li>
&lt;li>Decide where the user identifier comes from and set it in a single place, high up the precedence chain.&lt;/li>
&lt;li>Emit &lt;code>gen_ai.conversation.id&lt;/code> if you want neutral instrumentation, or Langfuse&amp;rsquo;s own attribute if coupling does not matter.&lt;/li>
&lt;li>Set the environment with &lt;code>deployment.environment.name&lt;/code>, with few distinct values and never one per tenant.&lt;/li>
&lt;li>Get scores out through their own channel, with the project keys, and do not expect them to travel with the trace.&lt;/li>
&lt;li>Truncate input and output on the client before sending them, especially with long agentic contexts.&lt;/li>
&lt;li>Put a redaction processor in the collector, because the SDK masking leaves out span events.&lt;/li>
&lt;li>Do not filter by tags in dashboards that get queried often.&lt;/li>
&lt;li>Send the ingestion version header, or use recent SDKs that send it by themselves.&lt;/li>
&lt;li>Review the size of the metadata: there is no schema cap, the cap is the one on the request body.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>What you see in a listing is truncated to two hundred characters&lt;/strong> by the materialised view, and the full read has another cap of a thousand. The data is whole in the full events table.&lt;/p>
&lt;p>&lt;strong>Metadata has no size limit in the schema.&lt;/strong> A blob of megabytes passes validation.&lt;/p>
&lt;p>&lt;strong>An OTLP span over 9.5 MB is not rejected, it is only logged.&lt;/strong> There is no signal back to the client.&lt;/p>
&lt;p>&lt;strong>The environment is declared low cardinality.&lt;/strong> One value per tenant degrades ClickHouse.&lt;/p>
&lt;p>&lt;strong>Scores do not travel over OTLP.&lt;/strong> It is by absence in the code, not by configuration.&lt;/p>
&lt;p>&lt;strong>The classic masking does not cover automatic instrumentation&lt;/strong>, and the new one does not cover span events, which is where the recent conventions put the messages.&lt;/p>
&lt;p>&lt;strong>Langfuse does not encrypt trace inputs or outputs.&lt;/strong> The encryption key protects integration credentials.&lt;/p>
&lt;p>&lt;strong>In self-hosted there are no rate limits on the API.&lt;/strong> It is no use as protection.&lt;/p>
&lt;p>&lt;strong>The classic ingestion route is not switched off on 16 November&lt;/strong>, it changes behaviour depending on the write mode, and it fails per event instead of per batch.&lt;/p>
&lt;p>&lt;strong>Model-based evaluators are not limited by plan&lt;/strong> in this version, contrary to what is usually assumed. What is limited are the annotation queues and the days of data access.&lt;/p>
&lt;h2 id="the-series-the-ten-articles">The series: the ten articles&lt;/h2>
&lt;p>This is the first. The order is designed so that each one can be read on its own and so that together they cover the whole of day 2.&lt;/p>
&lt;ol>
&lt;li>What goes into a trace (this article): version 4&amp;rsquo;s data model, limits, precedences, scores, masking and indexes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">Putting LangGraph in front&lt;/a>: instrumenting an agentic platform and the measured cost of one turn.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/">Two well-known agents instrumented&lt;/a>: Open Deep Research and GPT Researcher, with the measured figures of a real request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">Migrating from version 3 to 4 with no window&lt;/a>: the three write-mode steps, the resumable background migrations and where the rollback point of no return sits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/">The worker queues&lt;/a>: the map of all thirty-nine, which pool to dedicate to each group, the per-queue switches, sharding and concurrency.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/clickhouse-capacity-langfuse-v4-bytes-per-observation/">Real ClickHouse capacity and cost&lt;/a>: how to measure bytes per observation with the system tables, the difference between the full table and the listings table, and the merge cost of full-text indexes.&lt;/li>
&lt;li>Retention, deletion and data protection: why a deletion does not free disk, the mask cleaner that ships disabled, the pending deletion queue and the S3 lifecycle that has to be implemented by hand.&lt;/li>
&lt;li>Backup and cross recovery: restore ordering across Postgres, ClickHouse and object storage, what each mismatch breaks, and how far event replay goes.&lt;/li>
&lt;li>Saturation runbook: what to alert on from the queue metrics, the stuck probes, draining through the readiness endpoint and the dead letter queue.&lt;/li>
&lt;li>Getting the data out: the blob storage integration to Parquet, batch exports and the metrics API, to build the data lake.&lt;/li>
&lt;/ol>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a>: the six-service architecture and the ten performance levers of version 3.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a>: the four queues where the trace gets lost between the two pieces.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: why the trace user is not the Keycloak one, and when to put a collector in the middle.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>: the pipeline and the semantic conventions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a>: the tool&amp;rsquo;s other layer.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a>: where scores fit.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Código de Langfuse 4.35.0, commit &lt;code>39e3cd7d&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/changelog/2026-08-17-langfuse-v4">Anuncio de Langfuse v4&lt;/a>, 17 de agosto de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/compatibility">Compatibilidad y migración&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/observability/features/sessions">Sesiones&lt;/a>, &lt;a href="https://langfuse.com/docs/observability/features/sampling">muestreo&lt;/a> y &lt;a href="https://langfuse.com/docs/observability/features/masking">enmascarado&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/native/opentelemetry">Integración nativa con OpenTelemetry&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item><item><title>The gateway does not live alone: the JWT that does not validate the audience, the user who never reaches the trace, and the seams you have to write by hand</title><link>https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/</link><pubDate>Sat, 12 Sep 2026 06:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/</guid><description>&lt;blockquote>
&lt;p>Eighth article in the operational track of the control layer. The previous seven deal with the gateway from the inside: &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the pair with Langfuse&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">prefix routing&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">the MCP gateway&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">sizing for agents&lt;/a>. This one deals with what surrounds it. Verified against LiteLLM 1.102.0 and Langfuse 4.35.0, both with a commit of 11 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The gateway has five neighbours: a database, a cache, an identity provider, a trace backend and a collector. Each neighbourhood has its own physics, and these are the seven things it decides.&lt;/p>
&lt;p>&lt;strong>The proxy starts without Postgres, but then only the master key works.&lt;/strong> The &lt;code>DATABASE_URL&lt;/code> variable is optional (&lt;code>proxy_server.py:1163&lt;/code>); without it, any key other than the master one returns a 400 for a missing connection (&lt;code>user_api_key_auth.py:1886&lt;/code>). With a database configured but down, the default behaviour is to return 503, unless &lt;code>general_settings.allow_requests_on_db_unavailable&lt;/code> is switched on.&lt;/p>
&lt;p>&lt;strong>Redis is never mandatory, and that is precisely the trap.&lt;/strong> There is a circuit breaker with a threshold of five failures and a recovery of sixty seconds (&lt;code>constants.py:456&lt;/code>). When it opens, the proxy fails no request: it degrades to local memory. What is lost silently is that rate limits stop being global and become per pod.&lt;/p>
&lt;p>&lt;strong>No observability backend can bring a request down.&lt;/strong> All callbacks are launched with &lt;code>asyncio.create_task&lt;/code> and their exceptions are caught and logged (&lt;code>litellm_logging.py:3235&lt;/code>). If Langfuse does not respond, events are discarded as the queue fills up. There is no added latency, and there is no reliable record either: the pair&amp;rsquo;s observability is a statistical figure, not an audit record.&lt;/p>
&lt;p>&lt;strong>JWT authentication validates neither the audience nor the issuer unless two variables are defined.&lt;/strong> In &lt;code>_build_decode_kwargs&lt;/code> (&lt;code>handle_jwt.py:1003&lt;/code>) the audience comes from &lt;code>JWT_AUDIENCE&lt;/code> and the issuer from &lt;code>JWT_ISSUER&lt;/code>; if they are empty, the code sets &lt;code>verify_aud=False&lt;/code> and &lt;code>verify_iss=False&lt;/code> and emits a warning once only. Any valid token signed by that provider, issued for another service, is accepted.&lt;/p>
&lt;p>&lt;strong>Role-based access control ships disabled.&lt;/strong> &lt;code>enforce_rbac&lt;/code> has a default value of &lt;code>False&lt;/code> (&lt;code>_types.py:4848&lt;/code> onwards). With that value, a token whose role resolves to nothing does not produce a 403: it carries on through the flow. Real authorisation, then, comes from team membership, if there is a team claim configured at all.&lt;/p>
&lt;p>&lt;strong>The user identifier that reaches Langfuse is not the one from Keycloak.&lt;/strong> The &lt;code>user_id&lt;/code> field of the trace is filled in with &lt;code>user_api_key_end_user_id&lt;/code> (&lt;code>integrations/langfuse/langfuse.py:604&lt;/code>), that is, with the &lt;code>user&lt;/code> field of the OpenAI body, which the client sets. The subject of the token and the owner of the key do not travel. Anyone who wants per-person traceability needs to configure &lt;code>end_user_id_jwt_field&lt;/code> or make the client send that field.&lt;/p>
&lt;p>&lt;strong>Langfuse provisions nothing from a claim.&lt;/strong> There is no variable that maps an OIDC claim to an organisation or to a project. The only thing available is static assignment at sign-up with &lt;code>LANGFUSE_DEFAULT_ORG_ID&lt;/code> and &lt;code>LANGFUSE_DEFAULT_PROJECT_ID&lt;/code>, the same for everyone. On top of that, per-project access control sits behind the Enterprise licence in the self-hosted edition, as do audit logs and per-project retention.&lt;/p>
&lt;h2 id="you-are-here-between-the-four-pieces">You are here: between the four pieces&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse pair&lt;/a> walked through the seam between the gateway and the trace backend: integration routes, cost that arrives as zero, correlation and queues with discarding. This article widens the focus to the other three neighbourhoods, with identity at the centre, because that is the one that orders everything else and the one an auditor asks about.&lt;/p>
&lt;p>The identity piece itself, that is, Keycloak, is dealt with in &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">the next article&lt;/a>. Here we deal with the gateway side.&lt;/p>
&lt;h2 id="the-analogy-the-building-site-with-five-trades">The analogy: the building site with five trades&lt;/h2>
&lt;p>On a small building site, the problem is rarely inside each trade&amp;rsquo;s work, it is at the joins: where the plumber leaves the pipe sleeve and the bricklayer closes up the partition wall, where the electrician wants to run through the same gap. Each trade does its part well and the join ends up badly resolved because it belongs to nobody.&lt;/p>
&lt;p>An inference platform has five trades. The gateway knows about tokens, the identity provider knows about people, the trace backend knows about events, the database knows about spend and the cache knows about counters. Each one has good documentation. The joins have nobody&amp;rsquo;s documentation, and that is where the user&amp;rsquo;s identity gets lost between the token and the trace.&lt;/p>
&lt;p>There is one more detail of the analogy that is useful. On a building site, the part that causes the most problems is not the one that looks badly finished, it is the one that gets covered up. The same happens here: the five seams in this article fail silently.&lt;/p>
&lt;h2 id="seam-1-what-brings-things-down-and-what-degrades-them">Seam 1. What brings things down and what degrades them&lt;/h2>
&lt;p>It pays to have written down what happens when each neighbour falls over, because intuition fails in almost every case.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Neighbour&lt;/th>
&lt;th>Does it start without it?&lt;/th>
&lt;th>If it falls over&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Postgres&lt;/td>
&lt;td>Yes, master key only&lt;/td>
&lt;td>503 by default; degraded with &lt;code>allow_requests_on_db_unavailable&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Redis&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Circuit breaker after 5 failures; limits become per pod&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Events discarded as the queue fills; no impact on latency&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel collector&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Same as the previous one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Identity provider&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Access to the interface and the JWT flow goes down; virtual keys carry on&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="postgres">Postgres&lt;/h3>
&lt;p>Without &lt;code>DATABASE_URL&lt;/code>, &lt;code>_setup_prisma_client&lt;/code> returns &lt;code>None&lt;/code> without error (&lt;code>proxy_server.py:10375&lt;/code>). The proxy serves &lt;code>/chat/completions&lt;/code> with the master key. Any other key gets a 400. There is no global budget either, and start-up itself warns about it: Redis does not replace the database (&lt;code>proxy_server.py:9240&lt;/code>).&lt;/p>
&lt;p>With a database configured and down, the exception handler re-raises unless the degradation flag is active (&lt;code>db/exception_handler.py:56&lt;/code>). There is also a &lt;code>DISABLE_PRISMA_HEALTH_CHECK_ON_STARTUP&lt;/code> and a watcher that reconnects. The detail that matters for diagnosis: &lt;strong>a transient database failure returns 503, not 401&lt;/strong>. If during an incident you see 401s, the problem is credentials; if you see 503s, it is the database or admission.&lt;/p>
&lt;h3 id="redis">Redis&lt;/h3>
&lt;p>The circuit breaker is enabled by default, with a threshold of five failures, a recovery of sixty seconds and a minimum duration of five (&lt;code>constants.py:456&lt;/code>, logic in &lt;code>caching/redis_cache.py:153&lt;/code>). The socket has a timeout of 0.1 seconds (&lt;code>constants.py:453&lt;/code>). With the circuit open, &lt;code>DualCache&lt;/code> serves from memory without propagating an exception (&lt;code>dual_cache.py:215&lt;/code>).&lt;/p>
&lt;p>The consequence has to be measured, not assumed: with four workers and three replicas, a limit of one hundred requests per minute becomes one thousand two hundred. It is a failure mode that generates no errors and that can last for days without anyone noticing, until the bill arrives or the engine saturates.&lt;/p>
&lt;p>The right alert is not about Redis, it is about &lt;code>litellm_service_latency&lt;/code> labelled by service (&lt;code>integrations/prometheus_services.py:119&lt;/code>), which exposes Redis and database latency separately.&lt;/p>
&lt;h3 id="the-callbacks">The callbacks&lt;/h3>
&lt;p>Everything behind, that is, Langfuse and the collector, is launched outside the response path and its failures are caught. The classic integration uses the v2 SDK with its own background thread and a flush interval of one second, adjustable with &lt;code>LANGFUSE_FLUSH_INTERVAL&lt;/code> (&lt;code>langfuse.py:198&lt;/code>). There is a cap of fifty instantiated Langfuse clients (&lt;code>constants.py:554&lt;/code>), because each client is a thread.&lt;/p>
&lt;p>The OTLP integration uses the batch processor of the OpenTelemetry SDK (&lt;code>opentelemetry.py:3060&lt;/code>) with its default values. In both cases, saturation means discarding, and discarding generates no error towards the client.&lt;/p>
&lt;h2 id="seam-2-identity-the-three-doors-and-what-each-one-validates">Seam 2. Identity: the three doors and what each one validates&lt;/h2>
&lt;p>Everything goes through a single authentication constructor (&lt;code>user_api_key_auth.py:1245&lt;/code>), in this order:&lt;/p>
&lt;ol>
&lt;li>Custom authentication from the enterprise edition.&lt;/li>
&lt;li>Public routes.&lt;/li>
&lt;li>Opaque OAuth2, if enabled, with a licence check.&lt;/li>
&lt;li>JWT, if &lt;code>general_settings.enable_jwt_auth&lt;/code> is active and the token has three parts.&lt;/li>
&lt;li>Master key, with constant-time comparison.&lt;/li>
&lt;li>Virtual key, with SHA-256 hash and lookup in the verification table.&lt;/li>
&lt;/ol>
&lt;p>A JWT works for &lt;code>/chat/completions&lt;/code>, not just for administration: the routes allowed by default for a team include &lt;code>openai_routes&lt;/code> (&lt;code>_types.py:4886&lt;/code>), which contains the chat endpoint. Administration, on the other hand, is only reachable by a token with an administrator role.&lt;/p>
&lt;h3 id="the-first-thing-to-know-it-is-paid-for">The first thing to know: it is paid for&lt;/h3>
&lt;p>The JWT branch carries an explicit licence check, with the literal message that JWT authentication is an enterprise-only feature (&lt;code>user_api_key_auth.py:1416&lt;/code>). SSO for the administration interface is free up to five billable users and requires a licence above that (&lt;code>ui_sso.py:976&lt;/code>).&lt;/p>
&lt;p>This conditions the architecture of any sovereign deployment that does not want to pay for a licence: the Keycloak integration is left for the administration interface and for provisioning, while inference traffic authenticates with virtual keys. Virtual keys are not worse, but they are a different thing, and it has to be said in the risk analysis: they are credentials decoupled from the identity provider, so removing a person from Keycloak does not invalidate their key.&lt;/p>
&lt;h3 id="the-litellm_jwtauth-fields-that-decide-security">The &lt;code>litellm_jwtauth&lt;/code> fields that decide security&lt;/h3>
&lt;p>From the &lt;code>LiteLLM_JWTAuth&lt;/code> class (&lt;code>_types.py:4848&lt;/code> onwards), these are the ones that matter, with their default values:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>Default&lt;/th>
&lt;th>What it does&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>enforce_rbac&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>If false, an unresolved role does not produce a 403&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>enforce_scope_based_access&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Without this, &lt;code>scope_mappings&lt;/code> is not applied&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>enforce_team_based_model_access&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Model access by team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>team_id_jwt_field&lt;/code>&lt;/td>
&lt;td>&lt;code>None&lt;/code>&lt;/td>
&lt;td>Claim the team comes from&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>team_id_upsert&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>If false, a team that does not exist gives a 404&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_id_upsert&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Same with the user&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>public_key_ttl&lt;/code>&lt;/td>
&lt;td>&lt;code>600&lt;/code>&lt;/td>
&lt;td>Cache of the public keys&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>public_key_stale_ttl&lt;/code>&lt;/td>
&lt;td>&lt;code>3600&lt;/code>&lt;/td>
&lt;td>Extra margin if the provider is down&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>admin_jwt_scope&lt;/code>&lt;/td>
&lt;td>&lt;code>litellm_proxy_admin&lt;/code>&lt;/td>
&lt;td>Scope that grants administration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_allowed_email_domain&lt;/code>&lt;/td>
&lt;td>&lt;code>None&lt;/code>&lt;/td>
&lt;td>Restriction by email domain&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The accepted algorithms are RS256/384/512, PS256/384/512, ES256/384/512 and EdDSA (&lt;code>handle_jwt.py:163&lt;/code>). There is no symmetric one, which eliminates the algorithm confusion class of attack. It is a good design decision and it deserves recognition.&lt;/p>
&lt;p>Key download supports several comma-separated URLs in &lt;code>JWT_PUBLIC_KEY_URL&lt;/code>, resolves discovery if the URL points to the well-known OpenID configuration, retries three times with increasing waits and memoises the failure for thirty seconds (&lt;code>handle_jwt.py:680&lt;/code>, &lt;code>:751&lt;/code>, &lt;code>:88&lt;/code>). If the provider is down, it serves the stale copy up to the sum of the two TTLs, that is, up to seventy minutes. For availability that is fine; for revocation it is not, and it has to be written down.&lt;/p>
&lt;p>One detail of key rotation: with more than one key in the set, an exact match of the key identifier is required, but &lt;strong>with a single key and no identifier it is accepted without checking&lt;/strong> (&lt;code>handle_jwt.py:913&lt;/code>).&lt;/p>
&lt;h3 id="the-validation-that-does-not-happen">The validation that does not happen&lt;/h3>
&lt;p>This is the part to fix on day one. In &lt;code>_build_decode_kwargs&lt;/code> (&lt;code>handle_jwt.py:1003&lt;/code>), the audience is taken from the &lt;code>JWT_AUDIENCE&lt;/code> environment variable and the issuer from &lt;code>JWT_ISSUER&lt;/code>. If they are not defined, the code sets &lt;code>verify_aud=False&lt;/code> and &lt;code>verify_iss=False&lt;/code>, and emits a warning once only.&lt;/p>
&lt;p>What that means in a real deployment with Keycloak: a token issued for the Grafana client, or for the Backstage one, signed by the same realm, is accepted by the gateway as a valid credential. Expiry is always validated, with zero margin. So is the signature. The audience is not.&lt;/p>
&lt;p>There is a second route, &lt;code>issuers&lt;/code>, with per-issuer configuration, which does validate issuer and audience unless explicitly disabled (&lt;code>handle_jwt.py:1186&lt;/code>). That is the one to use when there is more than one provider.&lt;/p>
&lt;p>The minimum fix is two lines of environment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_AUDIENCE&lt;/span>&lt;span class="o">=&lt;/span>litellm-gateway
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the Keycloak side, an audience mapper in a dedicated client scope that injects that audience. The detail is &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the Keycloak article&lt;/a>, because the standard that would resolve this cleanly, resource indicators, is not supported.&lt;/p>
&lt;h3 id="from-claim-to-permission">From claim to permission&lt;/h3>
&lt;p>The administrator role comes from &lt;code>admin_jwt_scope&lt;/code> appearing in the &lt;code>scope&lt;/code> claim, and it returns a result without going through a team (&lt;code>handle_jwt.py:309&lt;/code>, &lt;code>:1373&lt;/code>). The rest is resolved by team.&lt;/p>
&lt;p>If the team in the claim does not exist in the database, with &lt;code>team_id_upsert&lt;/code> false a 404 is returned with the message that the team has to be created (&lt;code>auth_checks.py:3035&lt;/code>). With the flag active, it is created by invoking team creation with a synthetic administrator identity (&lt;code>auth_checks.py:2874&lt;/code>). That is the only automatic provisioning route that exists from Keycloak towards the gateway, and it works well. It pays to know that it creates teams with no budget and no limits, so a default value is needed in the configuration.&lt;/p>
&lt;h3 id="the-vulnerability-worth-knowing-about">The vulnerability worth knowing about&lt;/h3>
&lt;p>&lt;code>CVE-2026-35030&lt;/code>, scoring 9.1 on CVSS 3.1 and 9.4 on CVSS 4.0, advisory &lt;code>GHSA-jjhc-v7c2-5hh6&lt;/code>. With JWT authentication active, the cache of the OIDC user info endpoint used the first twenty characters of the token as the key, so that two tokens with the same prefix were confused with each other. Fixed in 1.83.0.&lt;/p>
&lt;p>In 1.102.0 the code uses the full SHA-256 hash (&lt;code>handle_jwt.py:962&lt;/code>). A search for similar patterns across the tree finds no authentication cache indexed by token prefix: the truncations that remain are masked for logging. And it is worth recalling what has already been published &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">about the MCP gateway&lt;/a>: below 1.83.14 there is unauthenticated remote execution or SQL injection.&lt;/p>
&lt;h2 id="seam-3-identity-does-not-reach-the-trace">Seam 3. Identity does not reach the trace&lt;/h2>
&lt;p>Here lies the most expensive design flaw in the arrangement, because it is discovered at the first audit.&lt;/p>
&lt;p>The &lt;code>user_id&lt;/code> field of a Langfuse trace is filled in with &lt;code>user_api_key_end_user_id&lt;/code> (&lt;code>integrations/langfuse/langfuse.py:604&lt;/code> and &lt;code>:724&lt;/code>), which comes from the &lt;code>user&lt;/code> field of the OpenAI request body or from &lt;code>end_user_id_jwt_field&lt;/code> (&lt;code>litellm_pre_call_utils.py:1584&lt;/code>). It is not the subject of the token nor the owner of the virtual key.&lt;/p>
&lt;p>The knock-on consequences:&lt;/p>
&lt;ul>
&lt;li>If the client does not send &lt;code>user&lt;/code>, the trace has no user.&lt;/li>
&lt;li>If the client does send &lt;code>user&lt;/code>, the trace has whatever value the client cares to put there, unverified.&lt;/li>
&lt;li>An agent calling with a service key leaves traces with no person behind them.&lt;/li>
&lt;/ul>
&lt;p>There are three ways to fix it, in order of robustness:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Configure &lt;code>end_user_id_jwt_field&lt;/code>&lt;/strong> so that the end user comes from a token claim and not from the body. It is the only option in which the value is not controlled by the client, and it requires JWT authentication, that is, a licence.&lt;/li>
&lt;li>&lt;strong>Force the &lt;code>trace_user_id&lt;/code> metadata field&lt;/strong>, which overwrites the field (&lt;code>langfuse.py:726&lt;/code>; on the OTLP route, &lt;code>langfuse_otel.py:96&lt;/code>). It can be set per team.&lt;/li>
&lt;li>&lt;strong>Contractually require the client to send &lt;code>user&lt;/code>&lt;/strong> and validate it in a guardrail. It works, but it is a client declaration, not a proof.&lt;/li>
&lt;/ol>
&lt;p>Something similar happens towards the engine. The caller&amp;rsquo;s identity does not travel to vLLM unless &lt;code>litellm.add_user_information_to_llm_headers&lt;/code> is switched on, whose default value is &lt;code>None&lt;/code> (&lt;code>litellm/__init__.py:223&lt;/code>). With it, &lt;code>x-litellm-*&lt;/code> headers are injected with user identifier, team identifier and key hash (&lt;code>litellm_pre_call_utils.py:1356&lt;/code>). Forwarding client headers requires &lt;code>general_settings.forward_client_headers_to_llm_api&lt;/code>, and the &lt;code>authorization&lt;/code> header is always filtered out, which is correct.&lt;/p>
&lt;p>Towards MCP servers, by default the server&amp;rsquo;s own credential is used, not the user&amp;rsquo;s token. Only two modes forward the caller&amp;rsquo;s token, and doing so has a name in the MCP specification: token passthrough, which is explicitly forbidden. It comes up again &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the Keycloak article&lt;/a>.&lt;/p>
&lt;h2 id="seam-4-langfuse-as-a-neighbour">Seam 4. Langfuse as a neighbour&lt;/h2>
&lt;h3 id="what-can-be-automated-and-what-cannot">What can be automated and what cannot&lt;/h3>
&lt;p>The exact list of variables for SSO in Langfuse 4.x, taken from &lt;code>web/src/env.mjs&lt;/code>:&lt;/p>
&lt;p>With the Keycloak provider: &lt;code>AUTH_KEYCLOAK_CLIENT_ID&lt;/code>, &lt;code>AUTH_KEYCLOAK_CLIENT_SECRET&lt;/code>, &lt;code>AUTH_KEYCLOAK_ISSUER&lt;/code>, &lt;code>AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING&lt;/code>, &lt;code>AUTH_KEYCLOAK_CLIENT_AUTH_METHOD&lt;/code>, &lt;code>AUTH_KEYCLOAK_CHECKS&lt;/code>, &lt;code>AUTH_KEYCLOAK_ID_TOKEN_SIGNED_RESPONSE_ALG&lt;/code>, &lt;code>AUTH_KEYCLOAK_SCOPE&lt;/code>, &lt;code>AUTH_KEYCLOAK_ID_TOKEN&lt;/code>, &lt;code>AUTH_KEYCLOAK_NAME&lt;/code>.&lt;/p>
&lt;p>With generic OIDC: the same set with the &lt;code>AUTH_CUSTOM_&lt;/code> prefix, plus &lt;code>AUTH_CUSTOM_FETCH_USERINFO&lt;/code> and claim mapping with &lt;code>LANGFUSE_CUSTOM_SSO_SUB_CLAIM&lt;/code>, &lt;code>LANGFUSE_CUSTOM_SSO_EMAIL_CLAIM&lt;/code>, &lt;code>LANGFUSE_CUSTOM_SSO_NAME_CLAIM&lt;/code> and &lt;code>LANGFUSE_CUSTOM_SSO_IMAGE_CLAIM&lt;/code>.&lt;/p>
&lt;p>Global ones: &lt;code>AUTH_DISABLE_USERNAME_PASSWORD&lt;/code>, &lt;code>AUTH_DISABLE_SIGNUP&lt;/code>, &lt;code>AUTH_DOMAINS_WITH_SSO_ENFORCEMENT&lt;/code>, &lt;code>AUTH_SESSION_MAX_AGE&lt;/code>.&lt;/p>
&lt;p>And now what is missing. &lt;strong>There is no variable that maps a claim to an organisation or a project.&lt;/strong> The only thing available is static assignment at sign-up with &lt;code>LANGFUSE_DEFAULT_ORG_ID&lt;/code>, &lt;code>LANGFUSE_DEFAULT_ORG_ROLE&lt;/code> with a default value of &lt;code>VIEWER&lt;/code>, &lt;code>LANGFUSE_DEFAULT_PROJECT_ID&lt;/code> and &lt;code>LANGFUSE_DEFAULT_PROJECT_ROLE&lt;/code> (&lt;code>features/auth/lib/createProjectMembershipsOnSignup.ts:61&lt;/code>). The documentation itself confirms it: enterprise SSO does not provision roles automatically at sign-up.&lt;/p>
&lt;p>On top of that, in the per-edition permissions table (&lt;code>features/entitlements/constants/entitlements.ts:139&lt;/code>), the open and self-hosted professional editions &lt;strong>do not include&lt;/strong> per-project access control or the administration API. They belong to the enterprise edition, as do audit logs and per-project retention. In the open edition, every user inherits the role of their organisation.&lt;/p>
&lt;h3 id="the-declarative-bootstrap-which-does-exist">The declarative bootstrap, which does exist&lt;/h3>
&lt;p>For on-premise without a licence, the workable route is bootstrapping through variables (&lt;code>web/src/initialize.ts&lt;/code>), which is idempotent: &lt;code>LANGFUSE_INIT_ORG_ID&lt;/code> (mandatory, if missing the rest is ignored with a warning), &lt;code>LANGFUSE_INIT_ORG_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_ID&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_RETENTION&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_PUBLIC_KEY&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_SECRET_KEY&lt;/code>, &lt;code>LANGFUSE_INIT_USER_EMAIL&lt;/code>, &lt;code>LANGFUSE_INIT_USER_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_USER_PASSWORD&lt;/code>.&lt;/p>
&lt;p>With that, one project per tenant is created from GitOps with one initialisation container per tenant and the keys in Kubernetes secrets.&lt;/p>
&lt;h3 id="langfuse-credentials-per-team">Langfuse credentials per team&lt;/h3>
&lt;p>This part is well resolved on the gateway side and few people use it. &lt;code>TeamCallbackMetadata.callback_vars&lt;/code> (&lt;code>_types.py:2152&lt;/code>) accepts &lt;code>langfuse_public_key&lt;/code>, &lt;code>langfuse_secret_key&lt;/code>, &lt;code>langfuse_host&lt;/code> and &lt;code>langfuse_environment&lt;/code>, with an allow list in &lt;code>initialize_dynamic_callback_params.py:67&lt;/code>. The gateway caches one logger per credential set (&lt;code>langfuse_handler.py:24&lt;/code>) and, on the OTLP route, builds a full exporter per key (&lt;code>langfuse_otel.py:400&lt;/code>).&lt;/p>
&lt;p>That is to say: &lt;strong>one Langfuse project per LiteLLM team is possible today&lt;/strong>, without a licence, and it is the correct way to isolate traces between tenants. What does not exist is automatic project creation, nor the correspondence between the team and the project. That is glue you have to write.&lt;/p>
&lt;h3 id="the-ingestion-route">The ingestion route&lt;/h3>
&lt;p>Langfuse accepts &lt;strong>only OTLP over HTTP&lt;/strong>, in &lt;code>http/protobuf&lt;/code> or &lt;code>http/json&lt;/code>. gRPC is not supported. The base route is &lt;code>/api/public/otel&lt;/code> and the signal lives at &lt;code>/api/public/otel/v1/traces&lt;/code>, verified in the repository. Authentication is Basic with the project&amp;rsquo;s key pair, plus the &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header for the version 4 route.&lt;/p>
&lt;p>The gateway builds it exactly like that (&lt;code>langfuse_otel.py:328&lt;/code>, &lt;code>:347&lt;/code>, &lt;code>:359&lt;/code>) and then normalises the endpoint by adding the signal suffix (&lt;code>opentelemetry.py:3237&lt;/code>). The result matches the real route, so the integration works without touching anything. The detail matters when a collector is put in the middle and somebody copies the endpoint by hand.&lt;/p>
&lt;p>And the calendar warning that already appeared &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">in the operational pair&lt;/a> still stands: the classic callback is tied to the v2 SDK and the date marked is 16 November 2026. With a nuance worth reading before scheduling an emergency migration, which is detailed &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">in the data model article&lt;/a>: that date applies to Langfuse Cloud, and in the code of the self-hosted version the old route is not switched off, it changes behaviour according to the write mode.&lt;/p>
&lt;h3 id="when-to-put-a-collector-in">When to put a collector in&lt;/h3>
&lt;p>Direct if Langfuse is the only trace destination. Via a collector if there is more than one consumer, or if any of these three things is needed that the proxy&amp;rsquo;s batch processor does not provide:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Retries with increasing waits and an on-disk queue&lt;/strong>, with the &lt;code>sending_queue&lt;/code> and &lt;code>file_storage&lt;/code> processors. With that, a Langfuse maintenance window stops losing traces.&lt;/li>
&lt;li>&lt;strong>Tail sampling&lt;/strong>, which lets you keep 100 % of errors and 1 % of the rest. The proxy cannot sample by outcome, because at the point it decides it does not yet know how things ended.&lt;/li>
&lt;li>&lt;strong>Attribute redaction&lt;/strong> before they leave the namespace. It is the only layer where you can delete the MCP tool arguments which, as has already been documented, are written in the clear, bypassing the message redaction switch.&lt;/li>
&lt;/ul>
&lt;h2 id="seam-5-postgres-redis-and-the-memory-policy">Seam 5. Postgres, Redis and the memory policy&lt;/h2>
&lt;p>&lt;strong>Separate Postgres, always.&lt;/strong> Each piece applies its own Prisma migrations over the public schema of the database it is given. Two Prisma clients migrating the same schema collide over table names and over migration history. On top of that, Langfuse 4.x requires Postgres 15 as a minimum, recommends 16 and wants UTC by default. The load profile is also the opposite: the gateway writes spend records at high frequency, while Langfuse does light transactional work because trace data goes to ClickHouse.&lt;/p>
&lt;p>&lt;strong>Separate Redis, and the argument is not the one it looks like.&lt;/strong> There is no key collision by design: the gateway uses hash tags of the form &lt;code>{api_key:...}:requests&lt;/code> and an optional namespace, and Langfuse uses the BullMQ prefix plus its own prefix for its key cache. The decisive argument is another one: &lt;strong>Langfuse requires &lt;code>maxmemory-policy=noeviction&lt;/code>&lt;/strong> because its queues are data, not cache, while the gateway assumes a disposable cache. Sharing an instance means that a spike of rate limit keys can fill the memory and cause write errors in the ingestion queues instead of a harmless eviction.&lt;/p>
&lt;p>If it is shared anyway, two minimum measures: &lt;code>REDIS_KEY_PREFIX&lt;/code> in Langfuse, &lt;code>namespace&lt;/code> in the gateway, and different logical databases.&lt;/p>
&lt;p>And a reminder about surface area: Langfuse 4.x &lt;strong>does not start without ClickHouse or without an S3-compatible bucket&lt;/strong>. &lt;code>CLICKHOUSE_URL&lt;/code> and &lt;code>LANGFUSE_S3_EVENT_UPLOAD_BUCKET&lt;/code> are mandatory and have no default value (&lt;code>packages/shared/src/env.ts:120&lt;/code> and &lt;code>:271&lt;/code>). Its failure surface is far larger than the gateway&amp;rsquo;s, and that changes where to put the high availability effort.&lt;/p>
&lt;h2 id="ports-for-the-networkpolicy">Ports, for the NetworkPolicy&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Port&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LiteLLM (API, interface and metrics)&lt;/td>
&lt;td>4000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse web&lt;/td>
&lt;td>3000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse worker&lt;/td>
&lt;td>3030&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ClickHouse&lt;/td>
&lt;td>8123 HTTP, 9000 native&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>S3-compatible storage&lt;/td>
&lt;td>9000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Redis&lt;/td>
&lt;td>6379&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Postgres&lt;/td>
&lt;td>5432&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel collector&lt;/td>
&lt;td>4317 gRPC, 4318 HTTP&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The pairs to allow under a default-deny policy, following the method from &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening the stack&lt;/a>:&lt;/p>
&lt;pre tabindex="0">&lt;code>litellm → postgres:5432, redis:6379
litellm → collector:4318 (or langfuse-web:3000 if going direct)
litellm → vllm:8000
litellm → keycloak:8443 (interface and JWT flow only)
collector → langfuse-web:3000
langfuse-web → postgres:5432, redis:6379, clickhouse:8123, s3:9000
langfuse-worker→ postgres:5432, redis:6379, clickhouse:8123, s3:9000
langfuse-web → keycloak:8443
ingress → litellm:4000, langfuse-web:3000
prometheus → litellm:4000
&lt;/code>&lt;/pre>&lt;p>The Langfuse worker needs no inbound traffic beyond the health probe.&lt;/p>
&lt;h2 id="the-glue-you-have-to-write">The glue you have to write&lt;/h2>
&lt;p>Put into a table, the real state of the tenant chain:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Segment&lt;/th>
&lt;th>Automatic?&lt;/th>
&lt;th>How&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Keycloak → LiteLLM team&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;code>team_ids_jwt_field&lt;/code> plus &lt;code>team_id_upsert: true&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → LiteLLM user&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;code>user_id_jwt_field&lt;/code> plus &lt;code>user_id_upsert: true&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → Langfuse organisation&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Static assignment at sign-up only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → Langfuse project&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Same&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM team → Langfuse project&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>&lt;code>callback_vars&lt;/code> by hand, or an operator of your own&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak user → trace &lt;code>user_id&lt;/code>&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>&lt;code>end_user_id_jwt_field&lt;/code>, or a contract with the client&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The minimum viable architecture without a licence, then, is this:&lt;/p>
&lt;ol>
&lt;li>One Langfuse project per tenant, created with the initialisation variables from GitOps.&lt;/li>
&lt;li>That project&amp;rsquo;s keys in a Kubernetes secret.&lt;/li>
&lt;li>A periodic job of your own that reads the teams through the gateway&amp;rsquo;s administration API and writes &lt;code>callback_vars&lt;/code> with the corresponding keys.&lt;/li>
&lt;li>People entering Langfuse through Keycloak, but assigned by hand to their organisation.&lt;/li>
&lt;li>The end user identifier resolved by contract with the clients, and validated.&lt;/li>
&lt;/ol>
&lt;p>That is about one hundred and fifty lines of your own code. It pays to budget for them from the start, because the gap does not close by itself.&lt;/p>
&lt;h2 id="reference-configuration">Reference configuration&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># only with a licence; without one, virtual keys&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_jwt_auth&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_jwtauth&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">team_ids_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;groups&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">team_id_upsert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sub&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_id_upsert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">end_user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;preferred_username&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_allowed_email_domain&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ejemplo.es&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admin_jwt_scope&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;litellm_proxy_admin&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># the three that ship switched off&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_rbac&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_scope_based_access&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_team_based_model_access&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allow_requests_on_db_unavailable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;prometheus&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, the engine does not know who is calling&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">add_user_information_to_llm_headers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">turn_off_message_logging&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">redis&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/REDIS_HOST&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># the two that enable the validation that by default does not happen&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_AUDIENCE&lt;/span>&lt;span class="o">=&lt;/span>litellm-gateway
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_PUBLIC_KEY_URL&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma/.well-known/openid-configuration
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_HOST&lt;/span>&lt;span class="o">=&lt;/span>http://langfuse-web.observabilidad.svc:3000
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_PUBLIC_KEY&lt;/span>&lt;span class="o">=&lt;/span>...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_SECRET_KEY&lt;/span>&lt;span class="o">=&lt;/span>...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the Langfuse side:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_CLIENT_ID&lt;/span>&lt;span class="o">=&lt;/span>langfuse
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_DISABLE_USERNAME_PASSWORD&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_DISABLE_SIGNUP&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_DEFAULT_ORG_ID&lt;/span>&lt;span class="o">=&lt;/span>plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_DEFAULT_ORG_ROLE&lt;/span>&lt;span class="o">=&lt;/span>VIEWER
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ENCRYPTION_KEY&lt;/span>&lt;span class="o">=&lt;/span>... &lt;span class="c1"># 64 hexadecimal characters&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Define &lt;code>JWT_AUDIENCE&lt;/code> and &lt;code>JWT_ISSUER&lt;/code> on day one, or use the per-issuer configuration. Without that, neither audience nor issuer is validated.&lt;/li>
&lt;li>Set &lt;code>enforce_rbac&lt;/code>, &lt;code>enforce_scope_based_access&lt;/code> and &lt;code>enforce_team_based_model_access&lt;/code> to true, and test that a token from another client of the same realm is rejected.&lt;/li>
&lt;li>Decide where the end user of the traces comes from and write it into the design. If it is not decided, traces come out with no person.&lt;/li>
&lt;li>Separate Postgres. Separate Redis. If Redis is shared, different prefix and logical database, and review the memory policy.&lt;/li>
&lt;li>Check that the proxy starts with Postgres down only if that is what you want, and that the on-call team can tell an admission 503 from a credential 401.&lt;/li>
&lt;li>Alert on &lt;code>litellm_service_latency&lt;/code> per service, not on Redis availability, because the circuit breaker hides the problem.&lt;/li>
&lt;li>One Langfuse project per tenant with the initialisation variables, and the per-team keys in &lt;code>callback_vars&lt;/code>.&lt;/li>
&lt;li>Put a collector in the middle if you need an on-disk queue, tail sampling or attribute redaction.&lt;/li>
&lt;li>Check that the gateway version is above 1.83.14 because of the earlier critical vulnerabilities.&lt;/li>
&lt;li>Write into the risk analysis that virtual keys are credentials decoupled from the identity provider, and define the offboarding procedure.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>A properly signed JWT is not a JWT addressed to this service.&lt;/strong> Without the two environment variables, the audience is not checked.&lt;/p>
&lt;p>&lt;strong>&lt;code>enforce_rbac&lt;/code> set to false does not mean there is no authorisation&lt;/strong>, it means that authorisation comes only from team membership, if there is a team claim. With no team claim and without the flag, a valid token reaches &lt;code>/chat/completions&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The stale copy of the public keys lasts up to seventy minutes&lt;/strong>, adding the two TTLs. Revoking a key at the provider does not take effect immediately.&lt;/p>
&lt;p>&lt;strong>With a single key in the JWKS and no key identifier, the identifier is not checked.&lt;/strong>&lt;/p>
&lt;p>&lt;strong>The trace user is set by the client.&lt;/strong> Any per-person spend report built on that field is a declaration, not a measurement.&lt;/p>
&lt;p>&lt;strong>Redis going down generates no errors&lt;/strong>, it generates per-pod limits. It is the most expensive of the failures that give no warning.&lt;/p>
&lt;p>&lt;strong>Langfuse does not start without ClickHouse or without an S3 bucket.&lt;/strong> Its failure surface is bigger than the gateway&amp;rsquo;s, and that changes how the high availability effort is shared out.&lt;/p>
&lt;p>&lt;strong>Langfuse&amp;rsquo;s per-project access control is paid for in self-hosted&lt;/strong>, as are audit logs. For an ENS file, that gets decided before building, not after.&lt;/p>
&lt;p>&lt;strong>Sharing Redis does not break through key collision, it breaks through memory policy.&lt;/strong> It is a failure that shows up under load and gets diagnosed badly.&lt;/p>
&lt;p>&lt;strong>Langfuse&amp;rsquo;s ingestion route is OTLP over HTTP only.&lt;/strong> A collector configured with the gRPC exporter points at somewhere that does not exist.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The five seams share a common pattern: they fail without warning. The token from another service gets in, the trace user comes from the client, the rate limit stops being global, the spend queue discards on reaching 64 MB and the Langfuse project does not create itself. None of those five things produces a visible error.&lt;/p>
&lt;p>Day 2 work, then, consists above all of turning silences into signals. Two environment variables so that the audience is validated, three flags so that authorisation is real, an alert on service latency instead of on Redis availability, and an explicit contract on where the end user&amp;rsquo;s identity comes from.&lt;/p>
&lt;p>And one decision worth taking early, before the budget is closed. The gateway&amp;rsquo;s JWT authentication and Langfuse&amp;rsquo;s per-project access control are both behind a licence. A sovereign platform can live perfectly well without either, with virtual keys and one project per tenant, but it is a different architecture and it has to be drawn as such from the start.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a>: the seam between gateway and traces, with the four queues that discard.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a>: the previous article in the track, with the size of each piece.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>: the identity piece from the inside and its position in the architecture.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second door, with its permissions and its privacy problem.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what goes into a trace&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack&lt;/a>: the default-deny method that orders the port table.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: adding authentication with Keycloak&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>Código de Langfuse 4.35.0, commit &lt;code>39e3cd7d&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/administration/authentication-and-sso">Autenticación y SSO en Langfuse&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/administration/scim-and-org-api">SCIM y API de organización&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/native/opentelemetry">Integración OpenTelemetry de Langfuse&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/postgres">Postgres&lt;/a> y &lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/cache">caché&lt;/a> en la documentación de self-hosting, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/proxy/token_auth">Autenticación por token en LiteLLM&lt;/a> y &lt;a href="https://docs.litellm.ai/docs/proxy/config_settings">ajustes de configuración&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;code>GHSA-jjhc-v7c2-5hh6&lt;/code> y registro de &lt;code>CVE-2026-35030&lt;/code> en NVD.&lt;/li>
&lt;/ul></description></item><item><title>Sizing for agents: 275 input tokens for every output token, and why the bottleneck is still decode</title><link>https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/</link><pubDate>Sat, 12 Sep 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/</guid><description>&lt;blockquote>
&lt;p>Seventh article in the operational track of the control layer. &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">The fourth&lt;/a> dealt with the coexistence of human traffic and agentic traffic on a fixed fleet. This one deals with the prior question: how big the fleet has to be, and how big the gateway in front of it. Verified against LiteLLM 1.102.0 (commit of 11 September 2026) and vLLM 0.29.0, with load data published between May and September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Until this year, sizing for agents meant extrapolating a chat profile and adding margin. In 2026 the first production-scale characterisations were published and the profile turned out to be something else. These are the seven conclusions.&lt;/p>
&lt;p>&lt;strong>The median prompt of an agentic call is 68,000 tokens, of which 63,000 arrive cached.&lt;/strong> The measurement covers 13.5 million sessions, 3.2 million users, 760.5 million model calls and 44.9 trillion prompt tokens in one week of June 2026. The median input-to-output ratio exceeds 275 to 1. Any calculation done on a profile of 800 prompt tokens and 250 output tokens describes a different system.&lt;/p>
&lt;p>&lt;strong>The load is prefill-dominant when counting tokens and decode-dominant when counting time.&lt;/strong> The genuinely new tokens that have to be prefilled on each turn are only between 1.5 and 7.3 times the output, not 275. With the prefix cache working, the split of execution time runs from 91 % to 98.6 % in decode. You size by memory bandwidth and by KV cache capacity, not by prefill FLOPs.&lt;/p>
&lt;p>&lt;strong>The sizing risk is not the average, it is the collapse of the prefix cache.&lt;/strong> The median hit rate within a turn is 98 %, it falls to 55 % at the boundary between turns and to 8 % when the client changes model. With dynamically retrieved context, prefix reuse drops to 1 %. One miss turns 142,000 prefix tokens into real prefill. Session affinity per replica stops being an optimisation and becomes a capacity requirement.&lt;/p>
&lt;p>&lt;strong>The intra-turn concurrency measured in production is 1.15 at the median and 1.4 at the 90th percentile.&lt;/strong> 93 % of tool batches contain a single invocation. The image of the agent opening twenty calls in parallel does not appear in the data. The real multiplier comes from the duty cycle and from the fact that 87 % of calls are started by the agent, not by the person.&lt;/p>
&lt;p>&lt;strong>The gateway is sized per worker, and each worker is a universe.&lt;/strong> One vCPU and 4 GiB per worker, with requests equal to limits. The limit counters, the caches and the cooldowns live in the worker&amp;rsquo;s memory unless there is Redis, so a limit of 100 requests per minute with four workers and three replicas allows 1,200. The Prisma pool is also per process: the number of connections open against Postgres is workers times &lt;code>connection_limit&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The CPU cost that grows with agentic load is the token counter.&lt;/strong> It serialises the entire tool catalogue on every request and tokenises it. The project itself measured 46, 53 and 100 ms for prompts of 50K, 75K and 100K tokens, and the operation passes through a concurrency limiter of four per worker. With agents sending long catalogues and six-figure contexts, that limit is the ceiling before the CPU is.&lt;/p>
&lt;p>&lt;strong>Autoscaling by memory does not work on this piece and the startup probe takes five minutes.&lt;/strong> The official chart deliberately leaves memory without a target because Prisma&amp;rsquo;s query engine never returns the maximum reached. That leaves CPU at 60 %, with a &lt;code>startupProbe&lt;/code> of 300 seconds that makes any new replica arrive late. For agents there are two better metrics, and they are in &lt;code>/metrics&lt;/code>.&lt;/p>
&lt;h2 id="you-are-here-the-arithmetic-that-comes-before-buying">You are here: the arithmetic that comes before buying&lt;/h2>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Classic capacity planning&lt;/a> starts from a chat SLO and crosses two budgets, the VRAM one and the time one. That method is still correct. What changes with agents are the inputs: the prompt length, the number of calls per unit of human work, the split between prefill and decode, and the very definition of an SLO. This article replaces those inputs with measured figures and drags the result through to two numbers: gateway replicas and GPUs.&lt;/p>
&lt;p>Out of scope is the splitting of traffic once it is built, which was already covered by &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the coexistence of humans and agents&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">prefix routing&lt;/a>.&lt;/p>
&lt;h2 id="the-analogy-the-repair-shop-and-the-front-desk">The analogy: the repair shop and the front desk&lt;/h2>
&lt;p>An appliance repair shop sizes its staff by counting faults, not phone calls. The difference matters because the person who brings in the washing machine occupies the front desk for three minutes, while the technician who takes it apart goes back to consult the machine&amp;rsquo;s data sheet fourteen times over the afternoon. If the shop manager sizes by visits to the front desk, they end up short of technicians and overstaffed at reception.&lt;/p>
&lt;p>The inference platform has the same asymmetry. The person sends one turn. The agent sends fifteen calls for that turn, and each one drags along the complete file of everything done so far. The front desk is the gateway and the technicians are the GPUs, and the usual mistake is to size both with the same counter.&lt;/p>
&lt;p>There is a second detail the analogy captures well. The technician does not read the whole data sheet each time: they have it open on the bench. That is the prefix cache. The day somebody moves them to a different bench between consultations, they have to fetch it from the archive again, and the work multiplies without the number of faults having increased.&lt;/p>
&lt;h2 id="part-1-the-real-load-profile">Part 1. The real load profile&lt;/h2>
&lt;p>For two years, any capacity calculation for agents was done on assumptions. In 2026 three pieces of work appeared that measure the matter, and it is worth reading them together because they measure different scaffolds and their figures are not averageable.&lt;/p>
&lt;h3 id="the-three-sources">The three sources&lt;/h3>
&lt;p>The characterisation at scale was published by a research team working on GitHub Copilot production telemetry (&lt;a href="https://arxiv.org/html/2608.00101v1">arXiv 2608.00101&lt;/a>, 30 July 2026): 13.5 million sessions, 3.2 million users, 95.1 million user turns, 760.5 million model calls and 44.9 trillion prompt tokens, in one week of June 2026. It is the largest sample published to date.&lt;/p>
&lt;p>The characterisation by benchmark suites comes from an architecture team (&lt;a href="https://arxiv.org/html/2605.26297">arXiv 2605.26297&lt;/a>, 25 May 2026), which instruments five of them (ADE-Bench, DABStep, GAIA, SWE-bench Pro and Terminal-Bench 2.0) and measures the internal split of time.&lt;/p>
&lt;p>The production measurement on a different scaffold was published by the vLLM project itself together with AgentX (&lt;a href="https://vllm.ai/blog/2026-09-08-vllm-agentx">vLLM blog&lt;/a>, 8 September 2026).&lt;/p>
&lt;p>The three disagree on turns per session: 15 calls at Copilot&amp;rsquo;s median, 43 turns in AgentX, 62.4 in SWE-bench Pro with Qwen. It is not a contradiction, they are scaffolds with different loops. The sizing lesson is that the number of calls per unit of human work depends on the client that connects, and that is why you have to measure it at home before fixing the size.&lt;/p>
&lt;h3 id="the-figures-you-can-actually-use">The figures you can actually use&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Quantity&lt;/th>
&lt;th>Measured value&lt;/th>
&lt;th>Source&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Median prompt per call&lt;/td>
&lt;td>68K tokens, 63K of them cached&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Input:output ratio (median)&lt;/td>
&lt;td>above 275:1&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Median session&lt;/td>
&lt;td>3 user turns, 15 calls, 4.2 min&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mean session&lt;/td>
&lt;td>6.1 turns, 40.6 calls, 62.6 min&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Calls per turn (median)&lt;/td>
&lt;td>4.5&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Calls started by the agent&lt;/td>
&lt;td>87 %&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Range across user profiles&lt;/td>
&lt;td>50× (23K to 1.1M tokens per turn)&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Median input context&lt;/td>
&lt;td>142K tokens; output 444 tokens per turn&lt;/td>
&lt;td>AgentX&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Accumulated context in SWE-bench Pro&lt;/td>
&lt;td>mean 68.7 to 80.1K, maximum 146 to 166K&lt;/td>
&lt;td>2605.26297&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reasoning tokens over the output&lt;/td>
&lt;td>29.0 % to 67.6 %&lt;/td>
&lt;td>2605.26297&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two operational observations come out of that and appear in no sizing guide.&lt;/p>
&lt;p>The first: &lt;strong>agents that fail accumulate up to 1.8 times more average context than those that finish well&lt;/strong> (2605.26297). The cost does not correlate with success, it correlates with difficulty and with getting stuck. A sizing done on the task that goes well falls short.&lt;/p>
&lt;p>The second: the range across user profiles is 50 to 1. A user who only converses spends 23,000 tokens per turn; one who leaves the agent in a long loop spends 1.1 million. Segmenting by usage profile pays off more than any difficulty-based routing, because the variance is in the people, not in the requests.&lt;/p>
&lt;h3 id="what-to-measure-before-calculating">What to measure before calculating&lt;/h3>
&lt;p>Three of your own figures, taken from a week of real traffic, are worth more than any table in this article:&lt;/p>
&lt;ol>
&lt;li>Model calls per hour of human work, by user profile.&lt;/li>
&lt;li>Prompt length distribution (median and 95th percentile), not the mean.&lt;/li>
&lt;li>Aggregate prefix cache hit rate, with the time series, not the single number.&lt;/li>
&lt;/ol>
&lt;p>All three come from what the gateway already stores. In &lt;code>LiteLLM_SpendLogs&lt;/code> you have the tokens per request and the key and team identifiers; in &lt;code>proxy_server_request&lt;/code> you have the &lt;code>tools&lt;/code> block of each call; and in vLLM&amp;rsquo;s &lt;code>/metrics&lt;/code> you have &lt;code>vllm:prefix_cache_queries&lt;/code> and &lt;code>vllm:prefix_cache_hits&lt;/code>.&lt;/p>
&lt;h2 id="part-2-prefill-decode-and-the-cache-trap">Part 2. Prefill, decode and the cache trap&lt;/h2>
&lt;p>Here is the finding that corrects almost everyone&amp;rsquo;s intuition, including mine when I started this article.&lt;/p>
&lt;p>Counting tokens, agentic load is overwhelmingly input: 275 to 1. The apparently obvious conclusion would be to size by prefill capacity, that is by FLOPs. It is false.&lt;/p>
&lt;p>What has to be prefilled on each turn is not the whole prompt, but what was added since the previous turn. That addition, measured, sits between 1.5 and 7.3 times the output length (2605.26297). The rest is served by the prefix cache. And the real split of the model&amp;rsquo;s execution time comes out like this: &lt;strong>between 91.0 % and 98.6 % in decode, between 1.4 % and 9.0 % in prefill&lt;/strong> (2605.26297).&lt;/p>
&lt;p>The hit rate that sustains that split is high and has been measured in three places: theoretical from 87.9 % to 99.3 % and empirical from 84.6 % to 99.5 % (2605.26297); above 96 % in production (AgentX); and a median of 98 % within a turn in Copilot (2608.00101).&lt;/p>
&lt;h3 id="where-it-falls-over">Where it falls over&lt;/h3>
&lt;p>The same Copilot work measures the degradation, and it is the most actionable figure in the article:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>98 %&lt;/strong> median hit rate within a turn. The detail matters: the first call of a turn hits around 45 %, the second rises to 86 % and from the third onwards it stabilises between 92 % and 94 %.&lt;/li>
&lt;li>&lt;strong>55 %&lt;/strong> at the boundary between turns, with idle gaps of 4.1 minutes at the median.&lt;/li>
&lt;li>&lt;strong>8 %&lt;/strong> when the client changes model.&lt;/li>
&lt;/ul>
&lt;p>And there is a fourth, worse regime: with dynamically retrieved context, that is when the scaffold injects search or RAG results that change on every iteration, prefix reuse falls to 1 % or less (&lt;a href="https://arxiv.org/html/2608.15127">arXiv 2608.15127&lt;/a>, 15 August 2026).&lt;/p>
&lt;p>A cache miss with a context of 142,000 tokens turns a prefill of a few thousand tokens into a six-figure one. The work multiplies by a factor of between 20 and 60 without the number of requests having changed. That is the difference between a fleet that meets its targets and one that seizes up mid-afternoon.&lt;/p>
&lt;p>Two sizing rules come from that:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Without session affinity towards the same replica, the calculated sizing is worthless.&lt;/strong> LiteLLM&amp;rsquo;s concrete mechanism is &lt;code>session_affinity&lt;/code>, which is not active by default and which was already covered &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">in the prefix routing article&lt;/a>. Without it, the calculation has to be redone assuming a prefill regime, which is between 20 and 60 times more expensive.&lt;/li>
&lt;li>&lt;strong>Changing model halfway through a session costs the entire cache.&lt;/strong> Any difficulty-based routing policy that moves a live conversation from one model group to another pays an 8 % hit rate on the next call. If you want to route by difficulty, do it at the start of the session.&lt;/li>
&lt;/ol>
&lt;h3 id="co-batching-which-is-the-other-effect-nobody-measures">Co-batching, which is the other effect nobody measures&lt;/h3>
&lt;p>Mixing a short request into the same batch as long-context requests degrades the short one&amp;rsquo;s inter-token latency. The measured figure: &lt;strong>one long request in the batch raises time per token by 38.3 %; two raise it by 79.7 %&lt;/strong> (2608.15127). It is the technical reason why separating human and agent pools improves the human experience more than the capacity split suggests, and it is also the reason not to raise &lt;code>--max-num-seqs&lt;/code> above what the KV budget allows.&lt;/p>
&lt;h2 id="part-3-the-kv-budget-with-the-numbers-of-the-agentic-case">Part 3. The KV budget, with the numbers of the agentic case&lt;/h2>
&lt;p>The formula does not change with respect to &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">classic capacity planning&lt;/a>, but the inputs do, and the result is surprising:&lt;/p>
$$\text{KV}_{\text{bytes/token}} = 2 \times n_{\text{layers}} \times n_{\text{kv heads}} \times d_{\text{head}} \times \text{bytes}_{\text{elem}}$$
&lt;p>For a 70B-class geometry (80 layers, 8 KV heads with grouped attention, head dimension 128, 2-byte elements) that gives 0.3125 MiB per token. With the median agentic context of 142,000 tokens:&lt;/p>
$$142{,}000 \times 0.3125\ \text{MiB} \approx 43.3\ \text{GiB per sequence}$$
&lt;p>For a 30B-class geometry (48 layers, everything else the same) that gives 0.1875 MiB per token, that is around 26.0 GiB per sequence.&lt;/p>
&lt;p>It is worth pausing on what that means. An 80 GB H100 with the weights of a 70B model in FP8 already taking up space does not have 43 GiB free for a single sequence. The conclusion is harsh and has to be written down: &lt;strong>serving real 70B-class agentic context demands tensor parallelism of at least 2 and a quantised KV cache&lt;/strong>. And the number of simultaneous sequences that fit, which is what sets &lt;code>--max-num-seqs&lt;/code>, comes from dividing the available KV by that figure, not from a chat heuristic.&lt;/p>
$$\text{sequences} \approx \frac{\text{VRAM}_{\text{KV available}}}{\text{KV}_{\text{bytes/token}} \times \text{max context}}$$
&lt;p>With FP8 in the KV, the divisor halves. It is the cheapest capacity lever there is in this load profile, and its cost in quality is validated with evals, not assumed.&lt;/p>
&lt;h2 id="part-4-from-people-to-concurrent-requests">Part 4. From people to concurrent requests&lt;/h2>
&lt;p>This is where most calculations overshoot, because it is assumed that an agent equals many simultaneous requests. The data says otherwise.&lt;/p>
&lt;p>The concurrency of calls within a turn, measured in production, is &lt;strong>1.15 at the median and 1.4 at the 90th percentile&lt;/strong>, and 93 % of tool batches contain a single invocation (2608.00101). Intra-turn parallelism is small. Subagents appear in 44 % of AgentX sessions, with a median of 4 deployments among those that use them, so there is a long tail, but it is not the usual regime.&lt;/p>
&lt;p>What does multiply is the duty cycle. The mean session lasts 62.6 minutes and contains 40.6 calls. At 444 output tokens and an interactivity of around 60 tokens per second, each call occupies around 7.4 seconds of decode, which gives between 5 and 7 active minutes out of those 62.6.&lt;/p>
$$C = D \times \rho \times A$$
&lt;p>where $D$ is the people working, $\rho$ the duty cycle and $A$ the amplification. With $\rho \approx 0.10$ to $0.12$ derived from the previous figures, twenty developers give between 2 and 2.4 concurrent sequences in the steady state. It is the peak, not the average, that has to be sized for.&lt;/p>
&lt;h3 id="the-amplification">The amplification&lt;/h3>
&lt;p>Three factors published separately:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tool failures&lt;/strong>: 9 % of turns trigger them, with a compute amplification of 4 times (2608.00101).&lt;/li>
&lt;li>&lt;strong>Subagents&lt;/strong>: 44 % of sessions, median of 4 deployments (AgentX).&lt;/li>
&lt;li>&lt;strong>Internal variance&lt;/strong>: the same task from the same agent varies by up to 30 times in cost within a single trace (2608.15127).&lt;/li>
&lt;/ul>
&lt;p>The product of the first two gives $(1 + 0.09 \times 3) \times (1 + 0.44 \times 3) \approx 2.9$. Nobody publishes that product as a single figure, so it is flagged as my own derivation, but &lt;strong>an amplification factor of 3 is the reasonable design provision&lt;/strong> and it fits the measured variance.&lt;/p>
&lt;p>For the twenty developers in the example: between 6 and 7 concurrent design sequences, with 142K contexts. At 43.3 GiB of KV per sequence in the 70B class, that is around 300 GiB of KV cache, that is four H100s dedicated to KV alone with the weights on top. With KV in FP8, half. With a 30B-class model, a third.&lt;/p>
&lt;p>That calculation, done with the chat profile of 800 prompt tokens, would have given a single GPU. That is the distance between the two regimes.&lt;/p>
&lt;h2 id="part-5-the-gateway-which-also-has-to-be-sized">Part 5. The gateway, which also has to be sized&lt;/h2>
&lt;p>The proxy consumes no GPU, but it has its own arithmetic and it is the part that produces the most surprises in production, because almost all of its state is per process.&lt;/p>
&lt;h3 id="a-worker-is-a-universe">A worker is a universe&lt;/h3>
&lt;p>The default value of &lt;code>--num_workers&lt;/code> is &lt;strong>1&lt;/strong> (&lt;code>litellm/constants.py:57&lt;/code>). The usual startup is uvicorn directly with uvloop (&lt;code>proxy_cli.py:1477&lt;/code>), and there are alternatives with gunicorn, hypercorn and granian.&lt;/p>
&lt;p>What lives in each worker&amp;rsquo;s memory, and therefore multiplies:&lt;/p>
&lt;ul>
&lt;li>The in-memory cache, limited to &lt;strong>200 elements&lt;/strong> with a TTL of 600 seconds (&lt;code>litellm/caching/in_memory_cache.py:27&lt;/code>). With many deployments, the cooldowns and the counters compete for that same space and get evicted before they expire.&lt;/li>
&lt;li>The key authentication cache, with a TTL of &lt;strong>60 seconds&lt;/strong> (&lt;code>proxy_server.py:1650&lt;/code>), adjustable with &lt;code>general_settings.user_api_key_cache_ttl&lt;/code>.&lt;/li>
&lt;li>The limit and budget counters when there is no Redis (&lt;code>parallel_request_limiter_v3.py&lt;/code>, &lt;code>local_only=True&lt;/code> paths).&lt;/li>
&lt;li>The router cooldowns, with &lt;code>DEFAULT_COOLDOWN_TIME_SECONDS = 5&lt;/code> (&lt;code>constants.py:75&lt;/code>).&lt;/li>
&lt;li>A periodic task scheduler of its own per worker (&lt;code>proxy_server.py:9674&lt;/code>), coordinated between pods with a lock in Redis.&lt;/li>
&lt;/ul>
&lt;p>The practical consequence: &lt;strong>a limit of 100 requests per minute with four workers and three replicas allows up to 1,200 without Redis&lt;/strong>. With Redis, the v3 limiter registers seven Lua scripts at startup (&lt;code>parallel_request_limiter_v3.py:608&lt;/code>) and the limits become global.&lt;/p>
&lt;p>One detail of the v3 limiter that matters with agentic load: &lt;code>LITELLM_TPM_TOKEN_RESERVATION_ENABLED&lt;/code> comes enabled by default and reserves estimated tokens before the call, which adds a round trip to Redis per request. And there is an &lt;code>asyncio.Lock&lt;/code> per instance that serialises the atomic batch checks (&lt;code>:665&lt;/code>), documented by the code itself as a 99th-percentile risk under contention.&lt;/p>
&lt;p>If Redis goes down, requests do not fail: there is a circuit breaker with a threshold of 5 failures and a recovery of 60 seconds (&lt;code>constants.py:456&lt;/code>), and the proxy degrades to local memory. What is lost silently is the globality of the limits.&lt;/p>
&lt;h3 id="postgres">Postgres&lt;/h3>
&lt;p>The pool is also per process. The default values are &lt;code>database_connection_pool_limit = 10&lt;/code> and &lt;code>database_connection_pool_timeout = 60&lt;/code> (&lt;code>proxy_cli.py:62&lt;/code>), injected as parameters of the Prisma URL. A pod with four workers opens forty connections. Three pods, one hundred and twenty. It is the fast route to exhausting Postgres&amp;rsquo;s &lt;code>max_connections&lt;/code> without having served any traffic, and the reason the official chart offers PgBouncer inside the pod itself with &lt;code>maxDbConnections: 20&lt;/code> and &lt;code>maxClientConn: 1000&lt;/code>.&lt;/p>
&lt;p>On the request path there is only the authentication, which is cached for 60 seconds, and the rate limiting. Spend is deferred. The &lt;code>SpendLogs&lt;/code> queue is capped at &lt;strong>64 MB&lt;/strong> and on overflow it discards the oldest elements with an error in the log (&lt;code>utils.py:6704&lt;/code>). The write interval is &lt;code>proxy_batch_write_at&lt;/code>, with a default value of 10 seconds plus a random spread of up to 5 (&lt;code>constants.py:1719&lt;/code>, &lt;code>proxy_server.py:9704&lt;/code>); the production guide recommends raising it to 60.&lt;/p>
&lt;p>With a slow Postgres, the queue grows to those 64 MB and then loses billing data without returning any error to the client. It is the silent failure mode you have to alert on.&lt;/p>
&lt;h3 id="the-token-counter-which-is-the-agentic-trap">The token counter, which is the agentic trap&lt;/h3>
&lt;p>This is the piece that turns a correct sizing into an insufficient one, and it appears in no guide.&lt;/p>
&lt;p>Before admitting a request, the proxy counts tokens. With chat load the cost is negligible. With agentic load it is not, for two reasons that add up.&lt;/p>
&lt;p>The first is the prompt length. The project itself measured the admission counting cost at &lt;strong>46, 53 and 100 ms for prompts of 50K, 75K and 100K tokens&lt;/strong> (&lt;a href="https://docs.litellm.ai/docs/benchmarks">benchmarks&lt;/a>, consulted on 12 September 2026), and published that with a counter written in Rust those figures fall to 4.9, 6.8 and 10.2 ms.&lt;/p>
&lt;p>The second is the tool catalogue. The function that prepares the definitions serialises the entire catalogue to a pseudo-TypeScript and tokenises it (&lt;code>litellm_core_utils/token_counter.py:934&lt;/code>), plus nine fixed tokens. The cost is linear in the size of the catalogue and it is paid on every request. That is exactly the pattern of an agent connected to an MCP gateway: a long catalogue, resent on every turn. The token bill of that catalogue was already covered &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway article&lt;/a>; here its other face appears, which is proxy CPU.&lt;/p>
&lt;p>And on top of that there is an explicit ceiling. The counting runs in a thread with a capacity limiter whose default value is &lt;strong>4 per worker&lt;/strong> (&lt;code>token_counter.py:340&lt;/code>, &lt;code>constants.py:407&lt;/code>). With 100K prompts, four concurrent counts of 100 ms mean a theoretical ceiling of around 40 admissions per second per worker before the limiter starts queueing, and that is without having called the model yet.&lt;/p>
&lt;p>What is cached is the tokeniser, with an LRU of &lt;strong>64&lt;/strong> entries (&lt;code>utils.py:2206&lt;/code>), not the result of the count.&lt;/p>
&lt;p>Three levers, in order of return: raise &lt;code>TOKEN_COUNTER_MAX_CONCURRENT_COUNTS&lt;/code> if CPU is spare, reduce the tool catalogue exposed per key, and watch the gateway overhead figure in &lt;code>/metrics&lt;/code> to know whether this is happening.&lt;/p>
&lt;h3 id="streaming-and-admission">Streaming and admission&lt;/h3>
&lt;p>There is no connection limit by default. &lt;code>--limit_concurrency&lt;/code> exists but is optional, and it returns 503. The built-in admission control is only activated if &lt;code>general_settings.max_in_flight_requests_per_worker&lt;/code> is defined (&lt;code>middleware/admission_control_middleware.py:285&lt;/code>), with &lt;code>max_queued_requests_per_worker&lt;/code> and &lt;code>admission_queue_timeout_seconds&lt;/code> at 1.0 by default, rejecting with 503 and a &lt;code>retry-after: 1&lt;/code> header.&lt;/p>
&lt;p>For agents, that header matters: an agentic client that retries without respecting it multiplies the load exactly when the gateway is saying it cannot take any more. The combination of gateway retries was already calculated by &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the fourth article&lt;/a> and it reached 45 calls per turn in the worst case.&lt;/p>
&lt;p>The default request timeout is &lt;strong>6,000 seconds&lt;/strong> (&lt;code>constants.py:512&lt;/code>). The production guide recommends 600. With long agentic tasks, leaving it at 6,000 means a hung connection occupies a slot for an hour and a half.&lt;/p>
&lt;h3 id="resources-and-autoscaling">Resources and autoscaling&lt;/h3>
&lt;p>The official chart recommends &lt;strong>1 vCPU and 4 GiB per worker, with requests equal to limits&lt;/strong> (&lt;code>helm/litellm-helm/values.yaml:235&lt;/code>), and the production documentation suggests one worker per pod scaling horizontally. The project&amp;rsquo;s own high-performance benchmark, by contrast, uses four workers per pod with PgBouncer in front.&lt;/p>
&lt;p>The HPA comes with &lt;code>targetCPUUtilizationPercentage: 60&lt;/code>. And there is a comment in the chart worth reading twice: &lt;strong>memory is deliberately left without a target&lt;/strong> because the resident consumption of Prisma&amp;rsquo;s query engine is a high-water mark that is never returned, so that an HPA on memory would add replicas and never remove them (&lt;code>values.yaml:260&lt;/code>).&lt;/p>
&lt;p>The other problem is startup. The &lt;code>startupProbe&lt;/code> has &lt;code>failureThreshold: 30&lt;/code> with a period of 10 seconds, that is &lt;strong>up to 300 seconds&lt;/strong> before the pod is considered ready (&lt;code>values.yaml:109&lt;/code>). An HPA that reacts at 60 % CPU and takes five minutes to add capacity arrives late to any agentic burst.&lt;/p>
&lt;p>The alternative, optional in the chart, is to scale by Prometheus metrics: &lt;code>targetRequestsPerSecond&lt;/code> and &lt;code>targetTokensPerSecond&lt;/code> over &lt;code>rate(litellm_proxy_total_requests_metric_total[1m])&lt;/code> and &lt;code>rate(litellm_total_tokens_metric_total[1m])&lt;/code>. The chart itself warns that the token metric lags with long streaming responses, because tokens are accounted for on completion. For agents, that is almost always.&lt;/p>
&lt;h2 id="part-6-what-to-watch-the-metrics-that-work-and-the-ones-that-broke">Part 6. What to watch: the metrics that work and the ones that broke&lt;/h2>
&lt;h3 id="at-the-gateway">At the gateway&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>What for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>litellm_in_flight_requests&lt;/code>&lt;/td>
&lt;td>Real saturation per worker. It is the scaling metric&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_overhead_latency_metric&lt;/code>&lt;/td>
&lt;td>Proxy cost per request; it rises with token counting&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_overhead_with_guardrails_latency_metric&lt;/code>&lt;/td>
&lt;td>The same including guardrails&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_request_queue_time_seconds&lt;/code>&lt;/td>
&lt;td>Arrival through to pre-call, includes authentication and ASGI queueing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_deployment_cooled_down&lt;/code>&lt;/td>
&lt;td>Replicas out of the pool&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_service_latency&lt;/code>&lt;/td>
&lt;td>Redis and database latency, per service&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>/health/backlog&lt;/code> endpoint returns &lt;code>in_flight_requests&lt;/code>, &lt;code>admitted_requests&lt;/code>, &lt;code>queued_requests&lt;/code> and &lt;code>rejected_requests&lt;/code> (&lt;code>health_endpoints/_health_endpoints.py:1871&lt;/code>).&lt;/p>
&lt;p>What it does &lt;strong>not&lt;/strong> expose: the depth of the &lt;code>SpendLogs&lt;/code> queue or its bytes, the Prisma connections in use against the limit, nor the depth of the logging worker&amp;rsquo;s queue. All three are real failure modes and have to be instrumented from outside.&lt;/p>
&lt;h3 id="at-the-engine">At the engine&lt;/h3>
&lt;p>Names verified in the vLLM 0.29.0 metrics documentation:&lt;/p>
&lt;ul>
&lt;li>&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:num_requests_waiting&lt;/code>&lt;/li>
&lt;li>&lt;code>vllm:time_to_first_token_seconds&lt;/code>, &lt;code>vllm:e2e_request_latency_seconds&lt;/code>, &lt;code>vllm:request_queue_time_seconds&lt;/code>, &lt;code>vllm:inter_token_latency_seconds&lt;/code>&lt;/li>
&lt;li>&lt;code>vllm:prefix_cache_queries&lt;/code>, &lt;code>vllm:prefix_cache_hits&lt;/code>, &lt;code>vllm:kv_cache_usage_perc&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>And the ones that break dashboards on upgrade:&lt;/p>
&lt;ul>
&lt;li>&lt;code>vllm:time_in_queue_requests&lt;/code> is duplicated by &lt;code>vllm:request_queue_time_seconds&lt;/code>.&lt;/li>
&lt;li>The direct prefix cache hit rate indicator disappeared. It is now computed as &lt;code>rate(vllm:prefix_cache_hits) / rate(vllm:prefix_cache_queries)&lt;/code>.&lt;/li>
&lt;li>&lt;code>vllm:num_requests_swapped&lt;/code> and &lt;code>vllm:cpu_cache_usage_perc&lt;/code> are deprecated along with CPU swapping.&lt;/li>
&lt;li>&lt;code>vllm:gpu_cache_usage_perc&lt;/code> became &lt;code>vllm:kv_cache_usage_perc&lt;/code>, which also breaks any HPA that uses it.&lt;/li>
&lt;/ul>
&lt;h3 id="the-slo-worth-chasing">The SLO worth chasing&lt;/h3>
&lt;p>A 95th percentile of time to first token describes nobody&amp;rsquo;s experience when the unit of work is 43 turns. Coding tasks frequently exceed ten minutes and research tasks reach hours (2608.15127).&lt;/p>
&lt;p>Two indicators serve better:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>90th percentile of full-task latency&lt;/strong>, measured per session, not per request. It comes from grouping &lt;code>LiteLLM_SpendLogs&lt;/code> by session identifier.&lt;/li>
&lt;li>&lt;strong>90th percentile of interactivity&lt;/strong>, in tokens per second per user. It is the one AgentX uses, with values between 58.3 and 74.2 in its runs.&lt;/li>
&lt;/ol>
&lt;p>To autoscale the engine, the official production-stack example uses &lt;code>vllm:num_requests_waiting&lt;/code> with a threshold of 5, polling every 15 seconds and a cooldown of 360. With agentic load it is worth adding &lt;code>vllm:kv_cache_usage_perc&lt;/code> as a second trigger, because with 26 to 43 GiB of KV per sequence the memory pressure arrives before the queue does.&lt;/p>
&lt;h2 id="part-7-the-engine-levers-ordered-by-return">Part 7. The engine levers, ordered by return&lt;/h2>
&lt;p>Verified against vLLM 0.29.0, released on 9 September 2026.&lt;/p>
&lt;p>&lt;strong>&lt;code>--long-prefill-token-threshold 512&lt;/code>.&lt;/strong> The AgentX measurement attributes to it &lt;strong>+93 % in tokens per GPU per second, with a 2.3-times improvement at the 90th percentile&lt;/strong>, by attacking the head-of-line blocking that a long prefill produces in front of short requests. It is the highest-return lever documented for this profile.&lt;/p>
&lt;p>&lt;strong>KV cache in FP8.&lt;/strong> It halves the memory budget per sequence, which is the limiting resource. Validate with evals.&lt;/p>
&lt;p>&lt;strong>&lt;code>--max-num-queued-reqs&lt;/code> and &lt;code>--max-num-queued-tokens&lt;/code>.&lt;/strong> New in 0.29. They are admission control in the engine, and they are the right mechanism to protect the SLO instead of letting the queue grow without limit.&lt;/p>
&lt;p>&lt;strong>&lt;code>max_num_batched_tokens&lt;/code> downwards.&lt;/strong> High values improve time to first token; low values improve inter-token latency. In a regime where 91 % of the time is decode, inter-token latency is what is perceived. Chunked prefill is enabled by default whenever possible in the V1 engine.&lt;/p>
&lt;p>&lt;strong>Session affinity at the gateway.&lt;/strong> It is not an engine setting, but it is what holds up everything above. Without it, that 98 % hit rate turns into random distribution across replicas.&lt;/p>
&lt;p>Two version warnings: Model Runner V2 becomes the default in 0.29 and V1 is deprecated with removal planned for 0.32; and starting with &lt;code>python -m vllm.entrypoints.openai.api_server&lt;/code> is deprecated.&lt;/p>
&lt;h2 id="the-full-calculation-from-start-to-finish">The full calculation, from start to finish&lt;/h2>
&lt;p>Case: twenty developers, a coding scaffold, a 30B-class model in the fleet, maximum context of 128K, KV in FP8.&lt;/p>
&lt;p>&lt;strong>1. Steady-state concurrency.&lt;/strong> $C = 20 \times 0.11 \times 3 \approx 6.6$ sequences.&lt;/p>
&lt;p>&lt;strong>2. KV budget per sequence.&lt;/strong> 0.1875 MiB per token in BF16, half in FP8, that is around 0.094 MiB. At 128K of context: around 12.0 GiB per sequence. For 6.6 sequences with margin, rounding to 8: 96 GiB of KV.&lt;/p>
&lt;p>&lt;strong>3. GPUs.&lt;/strong> The weights of a 30B in FP8 take up around 30 GB. On an 80 GB H100 with &lt;code>gpu_memory_utilization&lt;/code> of 0.90 there are around 72 GB usable per card. With tensor parallelism of 2, the weights are split: around 15 GB per GPU, so around 57 GB of KV per GPU and around 114 GB in the replica. That covers the 96 GiB calculated, and TP=2 also adds memory bandwidth, which is the resource that dominates decode. &lt;strong>Two H100s, one replica, TP=2.&lt;/strong> It is worth seeing the contrast: on a single H100 the full weights leave around 42 GB of KV, that is three and a half sequences, less than half of what is needed. With a second replica for availability and maintenance windows: four GPUs.&lt;/p>
&lt;p>&lt;strong>4. Requests per second at the gateway.&lt;/strong> 20 people, a mean session of 40.6 calls in 62.6 minutes, duty cycle included: of the order of 0.2 to 0.3 requests per second on average, with peaks of 2 to 3.&lt;/p>
&lt;p>&lt;strong>5. Gateway workers.&lt;/strong> The ceiling is not set by throughput but by token counting: four concurrent counts per worker, at 100 ms with long prompts. With peaks of 3 requests per second and six-figure prompts, &lt;strong>two replicas of one worker each&lt;/strong> cover the case with margin for the rolling update, at 1 vCPU and 4 GiB each. Postgres will see 20 connections.&lt;/p>
&lt;p>&lt;strong>6. What needs switching on.&lt;/strong> Session affinity, admission control at the gateway and at the engine, &lt;code>--long-prefill-token-threshold&lt;/code>, and Redis so that the limits are global.&lt;/p>
&lt;p>The same calculation with the chat profile would have given one GPU and one worker. The distance is not in the safety margin, it is in the inputs.&lt;/p>
&lt;h2 id="a-reference-configyaml">A reference &lt;code>config.yaml&lt;/code>&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/qwen3-coder-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rpm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000018&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, the capacity calculation in this article is worthless&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">optional_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;session_affinity&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allowed_fails&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldown_time&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># admission control: below this the queue grows without limit&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">24&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">48&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># the spend queue discards at 64 MB; shorter interval, less backlog&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_drain_endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">drain_endpoint_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/DRAIN_TOKEN&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">database_connection_pool_limit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># 6000 s by default: a hung connection holds a slot for an hour and a half&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;prometheus&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">redis&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/REDIS_HOST&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Environment variables that go with it:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NUM_WORKERS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">TOKEN_COUNTER_MAX_CONCURRENT_COUNTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">8&lt;/span> &lt;span class="c1"># default 4; raise only if CPU is spare&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_TPM_TOKEN_RESERVATION_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">PROXY_CONFIG_RELOAD_INTERVAL_SECONDS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">30&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the engine side:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen3-Coder-30B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --tensor-parallel-size &lt;span class="m">2&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">131072&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --long-prefill-token-threshold &lt;span class="m">512&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-queued-reqs &lt;span class="m">64&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.90
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Measure three figures of your own over a week: calls per hour of human work and per profile, prompt length distribution, and the time series of prefix cache hit rate.&lt;/li>
&lt;li>Calculate the KV per sequence with the model&amp;rsquo;s real geometry, taken from its &lt;code>config.json&lt;/code>, not from a table.&lt;/li>
&lt;li>Set &lt;code>--max-num-seqs&lt;/code> from that budget, not from a chat heuristic.&lt;/li>
&lt;li>Switch on session affinity before accepting the calculated number of GPUs.&lt;/li>
&lt;li>Switch on admission control in both layers and check that the client respects &lt;code>retry-after&lt;/code>.&lt;/li>
&lt;li>Lower &lt;code>request_timeout&lt;/code> from 6,000 to 600.&lt;/li>
&lt;li>Raise &lt;code>proxy_batch_write_at&lt;/code> to 60 and alert on the growth of the spend queue from outside, because there is no metric.&lt;/li>
&lt;li>Leave the HPA on CPU at 60 %, never on memory, and assume the 300 seconds of the &lt;code>startupProbe&lt;/code> when planning the margin.&lt;/li>
&lt;li>Watch &lt;code>litellm_in_flight_requests&lt;/code> and &lt;code>litellm_overhead_latency_metric&lt;/code>; if the overhead rises with the size of the tool catalogue, the culprit is the token counter.&lt;/li>
&lt;li>Go over the dashboards for the vLLM metric renames before upgrading.&lt;/li>
&lt;li>Check that no routing policy moves a live session between model groups.&lt;/li>
&lt;li>Redo the calculation when a new user profile arrives: the range across profiles is 50 to 1.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>The ratio of 275 to 1 does not mean the system is prefill-bound.&lt;/strong> The new tokens per turn are between 1.5 and 7.3 times the output. The time is between 91 % and 98.6 % in decode.&lt;/p>
&lt;p>&lt;strong>The aggregate prefix cache hit rate hides the problem.&lt;/strong> An aggregate 90 % may be 98 % intra-turn and 55 % at boundaries. You have to look at the series, not the number, and segment by request type.&lt;/p>
&lt;p>&lt;strong>vLLM&amp;rsquo;s hit rate counts the reprefill after an eviction as a hit.&lt;/strong> The counter exists internally and is not exposed, so the published number is optimistic under a memory-pressure regime.&lt;/p>
&lt;p>&lt;strong>Agents that fail cost more than those that succeed&lt;/strong>, up to 1.8 times more context. Sizing on the task that goes well leaves the calculation short.&lt;/p>
&lt;p>&lt;strong>The database pool is per process, not per pod.&lt;/strong> Four workers are forty connections.&lt;/p>
&lt;p>&lt;strong>The HPA on memory does not work with this piece&lt;/strong>, and not because of a configuration mistake: Prisma&amp;rsquo;s query engine does not return the memory.&lt;/p>
&lt;p>&lt;strong>The 300-second &lt;code>startupProbe&lt;/code> turns any autoscaling into late reaction.&lt;/strong> For agentic peaks, it is better to over-provision than to trust the scaling.&lt;/p>
&lt;p>&lt;strong>Raising &lt;code>--max-num-seqs&lt;/code> to &amp;ldquo;make use of the GPU&amp;rdquo; degrades perceived latency&lt;/strong>: two long requests in the batch raise time per token by 79.7 %.&lt;/p>
&lt;p>&lt;strong>The token counter has a limiter of four per worker&lt;/strong> and it appears in no sizing guide. With large tool catalogues and long contexts it is the first ceiling you hit.&lt;/p>
&lt;p>&lt;strong>The figures in this article come from three different scaffolds and are not averageable.&lt;/strong> Fifteen, forty-three and sixty-two turns per session are three systems, not three measurements of the same one.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The arithmetic of sizing has not changed. What has changed are the inputs, and now there are public measurements at scale instead of intuitions. Three figures sum up the change of regime: 68,000 tokens of median prompt, 87 % of calls started by the agent rather than by the person, and a time split that is decode for more than 90 % as long as the prefix cache hits.&lt;/p>
&lt;p>From that comes an order of work that is not the usual one. First, session affinity, because without it the rest of the calculation describes a different system. Second, the KV budget with the model&amp;rsquo;s real geometry, which is what sets how many sequences fit. Third, the gateway, where the ceiling is not throughput but a token counter with four threads per worker.&lt;/p>
&lt;p>And a warning about your own figures. The measured range across user profiles is 50 to 1. Any platform that does not segment its measurement by profile is averaging two different populations and producing a number that describes neither of them.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a>: coexistence on a fleet that is already sized.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing: what LiteLLM does not do&lt;/a>: the affinity mechanism on which this article&amp;rsquo;s entire calculation depends.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the other face of the tool catalogue, the one paid in tokens.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a>: the classic method whose inputs this article replaces.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: fundamentals&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix cache hit rate engineering&lt;/a>: the two pieces that dominate the budget.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">Day 2 of the proxy&lt;/a>: availability, draining and deployments.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/html/2608.00101v1">Agentic Coding in the Wild: Characterizing GitHub Copilot at Production Scale&lt;/a>, arXiv 2608.00101, 30 de julio de 2026.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/html/2605.26297">Agentic AI Workload Characteristics&lt;/a>, arXiv 2605.26297, 25 de mayo de 2026.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/html/2608.15127">From LLM Inference to Agentic Workloads&lt;/a>, arXiv 2608.15127, 15 de agosto de 2026.&lt;/li>
&lt;li>&lt;a href="https://vllm.ai/blog/2026-09-08-vllm-agentx">vLLM x AgentX: serving agentic workloads&lt;/a>, 8 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/design/metrics/">vLLM metrics design&lt;/a> y &lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">optimization guide&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/benchmarks">LiteLLM benchmarks&lt;/a> y &lt;a href="https://docs.litellm.ai/docs/proxy/prod">production setup&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/projects/production-stack/en/latest/use_cases/autoscaling-keda.html">Autoescalado con KEDA en vLLM production-stack&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item><item><title>LiteLLM's MCP gateway: the second front door, the tool catalogue nobody bills, and why filtering it can make selection worse</title><link>https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/</link><pubDate>Fri, 11 Sep 2026 01:45:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/</guid><description>&lt;blockquote>
&lt;p>Sixth post in the operational track on the control layer. The previous five treat the gateway as the piece the tokens pass through: &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the pair with Langfuse&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">prefix routing&lt;/a>. This one is about the other door. Verified against LiteLLM 1.102.0, commit of 10 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>The MCP gateway is not a module, it is a complete second surface.&lt;/strong> A native MCP endpoint with four route spellings, a proxy mode with three fixed tools, four REST routes, some thirty-five administration routes and a long set of OAuth discovery endpoints. It still lives under &lt;code>_experimental&lt;/code> and it is nearly 20,000 lines.&lt;/p>
&lt;p>&lt;strong>I correct what I said in the previous post: access is NOT open by default.&lt;/strong> In 1.102.0, a key with no declared permissions and no team resolves zero servers. What does open things up is a per-server flag, &lt;code>allow_all_keys&lt;/code>, and that flag &lt;strong>overrides the key&amp;rsquo;s explicit scope&lt;/strong> unless you enable a setting that is off by default.&lt;/p>
&lt;p>&lt;strong>Permissions are enforced on execution, not just on the listing.&lt;/strong> Calling a tool that did not appear in &lt;code>tools/list&lt;/code> returns 403. There are three chained checks before the call, including one on the allowed arguments. The same predicate governs listing and calling, by declared design.&lt;/p>
&lt;p>&lt;strong>The hierarchy is built out of intersections, with two escapes and a fail-open.&lt;/strong> The key&amp;rsquo;s access groups are additive on top of the key and team ceiling. The organisation ceiling replaces rather than intersects when there is nothing below it. And on an indeterminate error while resolving tool permissions, the code returns &amp;ldquo;unrestricted&amp;rdquo;. It is a deliberate decision and it has to be declared in the risk analysis.&lt;/p>
&lt;p>&lt;strong>Per-tool cost is worth zero and that is why its metric disappears.&lt;/strong> There are exactly two Prometheus metrics for MCP, and the spend one is only incremented if the cost is greater than zero. With the default configuration, that series never exists. There is no listing metric, no latency metric, no error metric, no server health metric.&lt;/p>
&lt;p>&lt;strong>The cost that does matter is not attributed to MCP anywhere.&lt;/strong> It is the tokens of the tool definitions, resent on every turn inside the &lt;code>prompt_tokens&lt;/code> of the model call. Five typical MCP servers add up to 58 tools and some 55,000 tokens. The tool&amp;rsquo;s spend row, with its zero cost, has nothing to do with that.&lt;/p>
&lt;p>&lt;strong>And the gateway&amp;rsquo;s semantic filter does not act on the MCP door.&lt;/strong> It only applies when it is LiteLLM calling the model. A client connected to &lt;code>/mcp&lt;/code> receives the whole catalogue, filter enabled or not. And even if it did act, filtering per request rewrites the cached prefix of the prompt, which is the opposite of what Anthropic and OpenAI do with their own tool searches.&lt;/p>
&lt;p>&lt;strong>The good observability ships switched off.&lt;/strong> Without &lt;code>LITELLM_OTEL_V2&lt;/code>, a tool call comes out as a generic span with the model set to &lt;code>MCP: name&lt;/code>. With it enabled there is a dedicated span and correct attributes, and the arguments and the result stay hidden unless you opt in. And in neither case is trace context propagated towards the upstream MCP server: the trace is cut at the gateway.&lt;/p>
&lt;p>&lt;strong>Tool arguments are written, and they are written in the clear.&lt;/strong> They go in full into the &lt;code>metadata&lt;/code> column of the spend table, without passing through the switch that governs prompt storage nor through the redaction function, which only knows about &lt;code>messages&lt;/code> and &lt;code>response&lt;/code>. Whoever enabled redaction for GDPR probably believes they are covered.&lt;/p>
&lt;p>&lt;strong>The 1.83 branch concentrated four serious vulnerabilities, and one of them belongs to the MCP gateway itself.&lt;/strong> The endpoint for testing a connection before saving accepted the stdio transport&amp;rsquo;s command, args and environment in the request body. A low-privilege key got command execution on the proxy host.&lt;/p>
&lt;p>&lt;strong>And the specification moved underneath.&lt;/strong> The revision in force since July 2026 removed protocol sessions, the initialisation handshake and the session header. LiteLLM 1.102.0 advertises the June 2025 revision and its version enumeration does not even contemplate the two later ones.&lt;/p>
&lt;h2 id="you-are-here-the-door-nobody-chose">You are here: the door nobody chose&lt;/h2>
&lt;p>The gateway was chosen for the reasons in &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">the switchboard post&lt;/a>: licence, fit inside the cluster, maturity. What was evaluated was the inference traffic.&lt;/p>
&lt;p>Later, somebody connects an agent, the agent needs tools, and the same process that is already authenticated and deployed turns out to speak MCP too. The decision to turn it into the tool door is almost never taken: it is inherited. And that door has neither the same controls, nor the same observability, nor the same cost model.&lt;/p>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows&lt;/a> covered the identity of your own MCP servers. &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside&lt;/a> covered the protocol and its instrumentation. This post covers the piece in between.&lt;/p>
&lt;h2 id="the-analogy-the-switchboard-that-also-hands-out-keys">The analogy: the switchboard that also hands out keys&lt;/h2>
&lt;p>An office building&amp;rsquo;s telephone switchboard. Its original job is to route calls: it decides who can call outside, how much each department spends, and it leaves a record of every conference call.&lt;/p>
&lt;p>Over time, the switchboard operator is also given the key cabinet. They are already at reception, they already know who everybody is, it looks like the natural place. And from that moment there are two services at the same desk with very different rules.&lt;/p>
&lt;p>Calls are priced by the minute and show up on the bill. Keys have no tariff, so on the department&amp;rsquo;s bill they come out at zero, and somebody might conclude that the key cabinet costs nothing. It does cost: it costs the operator&amp;rsquo;s time, it costs having to read the whole key catalogue every time somebody asks what is available, and it costs that nobody checks that the label on a key still says what it said the day its loan was authorised.&lt;/p>
&lt;p>The analogy ends at the interesting spot. When somebody takes a key, the record says which key it was and what they said they wanted it for. That record is simultaneously the evidence of the action and a piece of data that perhaps should not be stored in the clear.&lt;/p>
&lt;h2 id="the-real-routes">The real routes&lt;/h2>
&lt;p>The first thing that surprises you when you look at the code is how many spellings get you to the same place.&lt;/p>
&lt;h3 id="the-native-endpoint">The native endpoint&lt;/h3>
&lt;p>The MCP sub-application is mounted at &lt;code>/mcp&lt;/code> and inside it has four mounts: the root as a wildcard, &lt;code>/mcp&lt;/code>, &lt;code>/{name}/mcp&lt;/code> and &lt;code>/sse&lt;/code>.&lt;/p>
&lt;p>And there is also a hand-declared route for bare &lt;code>/mcp&lt;/code> on the main application, with a comment that explains why: the mount cannot match its own bare prefix, and the resulting 307 redirect breaks MCP clients sitting behind a proxy that terminates TLS. It is the kind of detail you discover in production on a Friday.&lt;/p>
&lt;p>&lt;code>/mcp/sse&lt;/code> holds a surprise. It does not use the classic SSE transport: it uses the same HTTP session manager in stateless mode. The &lt;code>SseServerTransport&lt;/code> object is constructed and is not referenced on any other line of the file. It is dead code, and the &lt;code>/mcp/sse/messages&lt;/code> endpoint that this object advertises does not exist as a working route.&lt;/p>
&lt;p>The &lt;code>/{name}/mcp&lt;/code> route resolves in a documented order: server alias, comma-separated list with a maximum of 16 elements, tool set, access group. If nothing matches, 404.&lt;/p>
&lt;p>And &lt;code>/mcp/proxy&lt;/code> exposes a fixed surface of three tools. It is the progressive disclosure pattern, and it will come up again further down because it is the most important mitigation in this post.&lt;/p>
&lt;h3 id="the-rest-routes">The REST routes&lt;/h3>
&lt;p>Four, under the &lt;code>/mcp-rest&lt;/code> prefix:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Route&lt;/th>
&lt;th>Authentication&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/mcp-rest/tools/list&lt;/code>&lt;/td>
&lt;td>Virtual key&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/tools/call&lt;/code>&lt;/td>
&lt;td>Virtual key&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/test/connection&lt;/code>&lt;/td>
&lt;td>Key plus administrator role&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/test/tools/list&lt;/code>&lt;/td>
&lt;td>Key plus administrator role&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The variants without &lt;code>-rest&lt;/code> that appear in the internal types have no router registering them. The &lt;code>/mcp/*&lt;/code> wildcard absorbs them as JSON-RPC.&lt;/p>
&lt;p>The last two are the stars of the security section.&lt;/p>
&lt;h3 id="administration-and-discovery">Administration and discovery&lt;/h3>
&lt;p>Some thirty-five routes under &lt;code>/v1/mcp&lt;/code>, almost all of them with virtual key authentication. Three details worth knowing:&lt;/p>
&lt;p>&lt;code>GET /v1/mcp/registry.json&lt;/code> &lt;strong>carries no authentication dependency&lt;/strong>. It returns 404 if the public registry is not enabled, which is the real protection.&lt;/p>
&lt;p>&lt;code>POST /v1/mcp/server&lt;/code> requires the proxy administrator role. &lt;code>POST /v1/mcp/server/register&lt;/code> does not: it is the proposal path, which requires a key with a team, explicitly forbids the stdio transport and leaves the entry pending review.&lt;/p>
&lt;p>And on top of that there is a long set of OAuth &lt;code>.well-known&lt;/code> endpoints, with dynamic client registration, authorisation, token, revocation and introspection, plus their per-server variants.&lt;/p>
&lt;h3 id="configuration-and-transport">Configuration and transport&lt;/h3>
&lt;p>The &lt;code>mcp_servers&lt;/code> schema in &lt;code>config.yaml&lt;/code> has fifty-odd fields, all of them optional. A warning for anyone coming from earlier versions: &lt;strong>&lt;code>spec_version&lt;/code> no longer exists&lt;/strong> in 1.102.0.&lt;/p>
&lt;p>The default transport when omitted is &lt;code>http&lt;/code>, that is, streamable HTTP. The other two values are &lt;code>sse&lt;/code> and &lt;code>stdio&lt;/code>. The string &lt;code>streamable_http&lt;/code> is not valid in the configuration file, although it is accepted as a synonym when importing connector files.&lt;/p>
&lt;p>Tool prefixing uses the format &lt;code>{server}{separator}{tool}&lt;/code> with &lt;code>-&lt;/code> as the default separator, configurable through an environment variable read at import time. The prefix chosen is the alias, or the name, or the identifier, in that order. There is an optional short mode that derives three characters from the hash of the server identifier and resolves collisions by rehashing.&lt;/p>
&lt;h2 id="access-control-and-the-correction-to-the-previous-post">Access control, and the correction to the previous post&lt;/h2>
&lt;p>In &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the humans and agents post&lt;/a> I closed the list of traps with this one: &amp;ldquo;access to MCP servers is open by default if no level defines a list&amp;rdquo;. Verified in 1.102.0, that is false.&lt;/p>
&lt;p>The function that resolves the allowed servers does the opposite. If the team declares nothing, it inherits from the key; if the key declares nothing either, the base set is empty and the scope is marked as unbounded with zero servers. There is no branch that expands to &amp;ldquo;the whole registry&amp;rdquo; other than the administration role one.&lt;/p>
&lt;p>What does open things up, and this is the important part, is something else.&lt;/p>
&lt;h3 id="allow_all_keys">&lt;code>allow_all_keys&lt;/code>&lt;/h3>
&lt;p>It is a per-server flag, in configuration, in the API and in the database, defaulting to false. When set to true, that server joins the set of open servers and any key can see it.&lt;/p>
&lt;p>The detail to know: &lt;strong>by default it overrides the key&amp;rsquo;s explicit scope&lt;/strong>. A key that declares access only to server A will also see server B if B has the flag. The key&amp;rsquo;s scope is only respected if you enable &lt;code>general_settings.mcp_allow_all_keys_respects_mcp_scope&lt;/code>, which is off by default.&lt;/p>
&lt;p>There is a second opening case: anonymous subjects, with no user identifier, role or key, additionally receive the servers configured with authentication delegation towards the upstream and the pass-through ones. It is consistent with the design of those two modes, and it is a surface you have to know about.&lt;/p>
&lt;h3 id="require_key_mcp_access_defined">&lt;code>require_key_mcp_access_defined&lt;/code>&lt;/h3>
&lt;p>It lives in &lt;code>general_settings&lt;/code>, defaults to false, and needs no licence. What it inverts is &lt;strong>only&lt;/strong> the empty-key inheritance towards the team: with the flag on, a key with no list of its own gets an empty set instead of inheriting the team&amp;rsquo;s.&lt;/p>
&lt;p>It does not affect keys that do declare servers, which still intersect with the team. And it does not close off the access group grants, which are additive and are the escape route you have to audit separately.&lt;/p>
&lt;p>There is a sibling, &lt;code>require_end_user_mcp_access_defined&lt;/code>, with the same shape and applied to the end user.&lt;/p>
&lt;h3 id="the-hierarchy">The hierarchy&lt;/h3>
&lt;p>The function&amp;rsquo;s docstring is normative and says that all the rules are intersections. In detail:&lt;/p>
&lt;ul>
&lt;li>Key and team: intersection when both declare. Inheritance in one direction when one is empty.&lt;/li>
&lt;li>Key access groups: &lt;strong>additive union&lt;/strong> on top of the previous result.&lt;/li>
&lt;li>End user: intersection if it declares anything.&lt;/li>
&lt;li>Agent: intersection.&lt;/li>
&lt;li>Internal user: ceiling that only narrows.&lt;/li>
&lt;li>Organisation: ceiling that intersects if there are restrictions below, and that &lt;strong>replaces&lt;/strong> by becoming the ceiling when there are none.&lt;/li>
&lt;/ul>
&lt;p>For subjects with no key, admitted by SSO or by the gateway, the model changes to a per-source union instead of an intersection.&lt;/p>
&lt;h3 id="the-fail-open">The fail-open&lt;/h3>
&lt;p>This is the point you have to take to the risk analysis and not leave in a technical note.&lt;/p>
&lt;p>On an indeterminate failure while resolving the tool permissions of a key or a JWT, the code returns &amp;ldquo;unrestricted&amp;rdquo;, that is, all tools allowed. The organisation ceiling is skipped in the same situation. For subjects with no key the behaviour is the opposite, fail-closed.&lt;/p>
&lt;p>There is a well-resolved intermediate case: when a permission is named but cannot be read, a specific error is raised and it is denied in both cases.&lt;/p>
&lt;p>The doctrine is documented in the class docstring, which is appreciated. And it remains a behaviour that, in a system under the ENS, you have to declare in writing instead of discovering it during an audit.&lt;/p>
&lt;h3 id="what-is-done-well">What is done well&lt;/h3>
&lt;p>Permissions are enforced on the call, not just on the listing. The execution path chains three checks before contacting the server: allowed or forbidden tools, per key and team tool permission, and validation of the arguments against the allowed ones. All three return 403.&lt;/p>
&lt;p>Listing and calling share the same predicate, and the docstring declares it as an explicit invariant. A client that calls a tool directly when it did not appear in its listing receives 403, it does not execute. The same over REST and over the responses API route.&lt;/p>
&lt;p>The &lt;code>x-mcp-servers&lt;/code> and &lt;code>x-mcp-access-groups&lt;/code> headers only narrow, and they fail closed if they do not resolve.&lt;/p>
&lt;p>Header forwarding towards the upstream is an explicit per-server allowlist, with a centralised decision about when to strip the caller&amp;rsquo;s &lt;code>Authorization&lt;/code>, and a protection against cross-forwarding: in a multi-server listing, the global header is withheld if more than one server would consume it. That is RFC 9700 applied.&lt;/p>
&lt;h2 id="the-bill-that-never-shows-up">The bill that never shows up&lt;/h2>
&lt;p>Here is, in my opinion, the most important thing in this post, and it has nothing to do with security.&lt;/p>
&lt;p>Every tool the gateway aggregates takes up space in the model&amp;rsquo;s context. Name, description and parameter schema travel in the request&amp;rsquo;s &lt;code>tools&lt;/code> block, &lt;strong>on every turn&lt;/strong>, and they are paid for as input tokens of the real model.&lt;/p>
&lt;p>The figures published by Anthropic in November 2025 are the best public reference there is. Five connected MCP servers add up to 58 tools and some 55,000 tokens. The breakdown: GitHub, 35 tools and some 26,000 tokens. Slack, 11 tools and some 21,000. Sentry, five and some 3,000. Grafana, five and some 3,000. Splunk, two and some 2,000.&lt;/p>
&lt;p>With tool search enabled, that set drops from some 72,000 to some 8,700 tokens, and the usable context goes from 122,800 to 191,300.&lt;/p>
&lt;p>The second cost of a large catalogue is not economic and is not billed: it is that the model chooses worse. It has its own section further down, because of the two it is the one that decides whether the agent is any good.&lt;/p>
&lt;h3 id="how-to-measure-it-with-what-is-already-there">How to measure it with what is already there&lt;/h3>
&lt;p>The gateway stores enough data, even if it does not cross-reference it for you.&lt;/p>
&lt;p>In the listing&amp;rsquo;s spend row, with call type &lt;code>list_mcp_tools&lt;/code>, it enriches the metadata with &lt;code>allowed_server_count&lt;/code>, &lt;code>tool_count_total&lt;/code>, &lt;code>per_server_tool_counts&lt;/code> and &lt;code>per_server_list_outcomes&lt;/code>.&lt;/p>
&lt;p>In the model call rows, the &lt;code>proxy_server_request&lt;/code> column keeps the request body with the &lt;code>tools&lt;/code> block inside it.&lt;/p>
&lt;p>The practical method: pair the listing rows with the LLM rows of the same session by &lt;code>session_id&lt;/code>, read the total number of tools from the first, and pass the &lt;code>tools&lt;/code> array of the second through &lt;code>litellm.token_counter(model=..., tools=...)&lt;/code>, which accepts that argument. Multiply by the number of turns, because the block is resent on all of them.&lt;/p>
&lt;p>For the relative cost, a quicker comparison: mean &lt;code>prompt_tokens&lt;/code> of keys with MCP enabled against those without, same model and same period.&lt;/p>
&lt;h2 id="tool-selection">Tool selection&lt;/h2>
&lt;p>The previous section treats the catalogue as a bill. This one treats it as what determines whether the agent gets it right. This is ground where the talk runs well ahead of the measurement, so it is worth separating what has been measured from what gets repeated.&lt;/p>
&lt;h3 id="how-many-tools-a-model-can-take">How many tools a model can take&lt;/h3>
&lt;p>Anthropic&amp;rsquo;s official figure is that Claude&amp;rsquo;s ability to pick the right tool degrades when going from 30 to 50 available tools. The same documentation gives the inverse criterion, which is the more useful one: the full catalogue with nothing else is the right option with fewer than ten tools, when all of them are used on every request, or when the definitions add up to fewer than a hundred tokens.&lt;/p>
&lt;p>There is also an administrative ceiling before the semantic one. VS Code with Copilot cuts off at &lt;strong>128 tools per request&lt;/strong> and returns an error when you exceed it.&lt;/p>
&lt;p>The full curve gets cited far more than it has been measured. The soundest primary data I found is from May 2025: varying the number of candidates from 1 to 11,100 over a registry of more than 4,400 servers, below some 30 tools the success rate is above 90 %, between 31 and 70 intermittent failures appear, and past the hundred mark the degradation becomes dominated by retrieval. It is a qualitative heat map, not a numeric table.&lt;/p>
&lt;p>The figure that circulates most (78 % with ten tools, 40 % with a hundred, 13.6 % with seven hundred) comes from a conference talk with no paper, no repository and no published methodology. Do not take it to a client presentation.&lt;/p>
&lt;p>And there is a warning that affects everything else. An audit of 496 tasks from four tool calling benchmarks found &lt;strong>18.5 % misalignment between the label and reality&lt;/strong>. In the BFCL v4 subset, 80 % of the failures assigned to the agent came from fragile state comparisons. In one of the benchmarks with an automatic judge, 23 identical re-runs gave a range from 57.9 % to 76.8 %, almost 19 points of spread, enough to reorder the ranking. Any absolute figure in this section deserves a generous margin of error.&lt;/p>
&lt;h3 id="how-it-fails">How it fails&lt;/h3>
&lt;p>The work with the cleanest taxonomy evaluates 36 servers, 220 tools and 1,000 tasks against twenty frontier models, with an average of 15.2 tools exposed per task of which 4.1 are relevant.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Failure mode&lt;/th>
&lt;th>Share&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Not using a tool when one was needed&lt;/td>
&lt;td>10.5 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Choosing the wrong tool&lt;/td>
&lt;td>9.0 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Malformed parameters&lt;/td>
&lt;td>6.9 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Not recovering from an error&lt;/td>
&lt;td>10.3 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cognitive failures, not tool failures&lt;/td>
&lt;td>63.3 %&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The interesting part is in the per-model breakdown: &lt;strong>the dominant failure mode changes with the model, not with the size of the catalogue&lt;/strong>. In one of them, 40.1 % of the failures are simply not calling any tool. In others the malformed parameter dominates. In another, the wrong tool. There is no canonical failure to attack.&lt;/p>
&lt;p>On tools that do the same thing, which is the real case of a gateway with fifteen servers, there is a good measurement. A study with ten groups of five functionally equivalent tools and a thousand query pairs puts the selection bias between 0.3 and 0.4, that is, &lt;strong>you would have to redistribute between 30 and 40 % of the probability mass for equivalent tools to be chosen equally&lt;/strong>. The same work shows that a biased continued training takes the selection of a specific endpoint from 0.6 % to 12.8 %.&lt;/p>
&lt;p>That study contributes another figure that reorders priorities: shuffling a tool&amp;rsquo;s &lt;strong>description&lt;/strong> moves the selection distribution substantially, whereas &lt;strong>changing only the name has minimal and inconsistent effects&lt;/strong>. Applied to the gateway&amp;rsquo;s &lt;code>server-tool&lt;/code> prefixing: it costs tokens and probably does not change which tool the model goes to. There is no study isolating the effect of name spacing, and it is a real gap in the literature.&lt;/p>
&lt;p>If the description is what carries weight, the state of the estate is the problem. An audit of 856 tools from 103 MCP servers found undeclared limitations in 89.8 %, missing usage guidance in 89.3 % and opaque parameters in 84.3 %. &lt;strong>Only 2.9 % were free of problems across all their components.&lt;/strong>&lt;/p>
&lt;h3 id="the-optimum-is-not-a-number">The optimum is not a number&lt;/h3>
&lt;p>The May 2026 work I cited above proposes a chance-corrected metric and uses it to size the list adaptively. Over 370 functions it reaches 90.3 % coverage while seeing an average of 7.4 tools, against 90.8 % seeing a fixed 50.&lt;/p>
&lt;p>But the same paper publishes its own counterexample, and it is honest to cite it: over another benchmark of 3,251 tools, &lt;strong>the fixed K of 5 wins in aggregate&lt;/strong>, 64.7 % against 61.9 %. The adaptive advantage concentrates in the hard queries, where the right tool sits between sixth and twentieth by similarity: there it finds 16.7 % of the cases where the fixed K finds 0 %.&lt;/p>
&lt;p>The March 2026 work over 121 tools puts the plateau at K=3, with 97.1 % that does not improve going up to K=5. With the caveat that it measures retrieval against labels, not model accuracy.&lt;/p>
&lt;p>And a July 2026 work gives the formulation that seems to me the right one: ranking by score is inconsistent with optimal acquisition when costs are heterogeneous, so &lt;strong>the optimal K is a function of cost, not a constant&lt;/strong>. Trimming from 7 to 4.4 exposed tools while keeping success is possible; fixing a universal number is not.&lt;/p>
&lt;h3 id="filtering-can-make-the-result-worse">Filtering can make the result worse&lt;/h3>
&lt;p>This is the part that appears in no product blog. A June 2026 benchmark with three registry sizes (25, 100 and 250 tools), six filtering methods, seven models and 26,460 runs:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Strategy&lt;/th>
&lt;th>Success&lt;/th>
&lt;th>Visible tools&lt;/th>
&lt;th>Tokens&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Expose the whole catalogue&lt;/td>
&lt;td>32.1 %&lt;/td>
&lt;td>125.00&lt;/td>
&lt;td>56,062&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keyword, top-5&lt;/td>
&lt;td>22.1 %&lt;/td>
&lt;td>4.80&lt;/td>
&lt;td>3,200&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keyword, top-10&lt;/td>
&lt;td>22.4 %&lt;/td>
&lt;td>9.54&lt;/td>
&lt;td>5,356&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>State-aware&lt;/td>
&lt;td>24.0 %&lt;/td>
&lt;td>25.95&lt;/td>
&lt;td>13,522&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Full causal path&lt;/td>
&lt;td>24.0 %&lt;/td>
&lt;td>26.24&lt;/td>
&lt;td>13,697&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Four of the five filtering strategies perform worse than not filtering.&lt;/strong> The fifth, which does win comfortably, exposes 0.99 tools per step and requires hand-written precondition and effect contracts for every tool, plus a search of the causal path to the goal state. It is a planner with the goal given, not a retrieval filter, and it is not comparable with anything a gateway offers.&lt;/p>
&lt;p>There is more evidence in the same direction. A work over a corpus of 43,000 tools measured that the agent&amp;rsquo;s completion rate drops when using retrieved sets against hand-annotated sets: the filter introduces its own recall ceiling. And top-K breaks the tasks that need several tools: on a benchmark of 16,464 endpoints, a fine-tuned retriever reaches a Recall@3 of 68.6 % but only 39.7 % of the queries have their &lt;strong>complete&lt;/strong> tool set within the first three. Almost 29 points of gap between &amp;ldquo;the one I needed is there&amp;rdquo; and &amp;ldquo;all the ones I needed are there&amp;rdquo;.&lt;/p>
&lt;p>Filtering is not free on security either. A March 2026 work attacks the retrieval layer by injecting adversarial tools that saturate the top-k: with injection rates of 1.2 % it manages to dominate the top-k between 91 % and 97 % of the time. It is an attack surface that exposing the whole catalogue does not have.&lt;/p>
&lt;p>The most elegant counterpoint comes from Anthropic&amp;rsquo;s own numbers. With the same catalogue of more than fifty tools, the gain from tool search drops from 25 points on one model to 8.6 on the next, because the unfiltered baseline rises from 49 % to 79.5 %. &lt;strong>The margin left by filtering shrank to a third in one generation of models.&lt;/strong> Any architecture decision taken here has a short shelf life.&lt;/p>
&lt;h3 id="the-prompt-cache-trap">The prompt cache trap&lt;/h3>
&lt;p>Here is what makes a homemade top-K filter a bad idea even if it worked.&lt;/p>
&lt;p>Tool definitions do not go just anywhere in the prompt: they go at the beginning. Anthropic documents the order of the cacheable prefix as &lt;code>tools&lt;/code>, then &lt;code>system&lt;/code>, then &lt;code>messages&lt;/code>, with a hierarchy where each level rests on the previous one. And the invalidation rule is explicit: &lt;strong>modifying tool definitions, be it names, descriptions or parameters, invalidates the entire cache&lt;/strong>, and a change at one level invalidates that level and all subsequent ones.&lt;/p>
&lt;p>OpenAI says the same and adds a detail: the prefix must remain unchanged, and &lt;strong>order counts&lt;/strong>. The same set of tools served in a different order is a cache miss.&lt;/p>
&lt;p>With vLLM serving an open model there is no tool block of its own. The &lt;code>tools&lt;/code> parameter is serialised by the chat template inside the first system message, behind the system prompt text. For automatic prefix caching the effect is identical, because it is still near the absolute start of the prompt.&lt;/p>
&lt;p>The consequence is the one that matters. A filter that returns a different set of tools on the next turn does not just invalidate the tool block: it invalidates &lt;strong>everything behind it&lt;/strong>, which is the system prompt and the whole conversation, which is exactly the part that grows with the turns. At list prices, a prefix token goes from costing 0.1 times when read from cache to 1.25 times when written. An agent with sixty thousand tokens of stable prefix goes from some six thousand equivalent tokens per turn to some seventy-five thousand, plus the prefill that gets paid again in latency.&lt;/p>
&lt;p>The two big providers have separately arrived at the same solution, and it is the opposite of filtering in the prefix. Anthropic excludes deferred tools from the prefix and, when the model discovers one through search, injects it inline inside the conversation: &amp;ldquo;the prefix stays intact, so prompt caching is preserved&amp;rdquo;. OpenAI: &amp;ldquo;all tools are loaded at the end of the model&amp;rsquo;s context window (&amp;hellip;) this allows the cache to be preserved from one request to the next&amp;rdquo;. Anthropic&amp;rsquo;s operational recommendation is the stable core pattern: leave the three to five most used tools undeferred.&lt;/p>
&lt;p>The closest academic work, from January 2026, states the problem without measuring it and recommends exactly that, keeping a fixed set of reusable general-purpose functions and implementing dynamic capability through code generation.&lt;/p>
&lt;p>The July 2026 MCP specification picks up both halves of the matter. Servers &amp;ldquo;&lt;strong>should&lt;/strong> return the tools from &lt;code>tools/list&lt;/code> in a deterministic order to allow caching in the client and improve the hit rate of the model&amp;rsquo;s prompt cache&amp;rdquo;. And it adds mandatory &lt;code>ttlMs&lt;/code> and &lt;code>cacheScope&lt;/code> in listing results, so that the client can freeze the catalogue for a known window instead of re-querying it and propagating any variation to the prompt.&lt;/p>
&lt;p>Nobody has publicly measured the cost in euros of this effect. The only attempt I found is a self-published audit of ten thousand turns that puts it at 2.4 % to 3.5 % of cache misses, with non-reproducible methodology. It is a gap in the literature, and the order of magnitude in the previous paragraph comes from applying list prices, not from a measurement.&lt;/p>
&lt;h3 id="and-now-litellm">And now, LiteLLM&lt;/h3>
&lt;p>With all of the above in mind, the gateway&amp;rsquo;s levers read differently.&lt;/p>
&lt;p>&lt;strong>The semantic filter does not act on the MCP endpoint.&lt;/strong> It is registered as a generic LiteLLM callback, and its only dispatcher is the proxy&amp;rsquo;s precall hook, which additionally discards anything that is not &lt;code>completion&lt;/code>, &lt;code>acompletion&lt;/code> or &lt;code>aresponses&lt;/code>. The &lt;code>tools/list&lt;/code> path does not invoke that hook at any point; there is a comment in the code that admits it in passing, when explaining why the listing&amp;rsquo;s JWT signature is done separately. An external MCP client connected to &lt;code>/mcp&lt;/code> receives the whole catalogue, filter enabled or not.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Lever&lt;/th>
&lt;th>Serves external MCP clients&lt;/th>
&lt;th>Breaks the prompt cache&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Per-server &lt;code>allowed_tools&lt;/code>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, it is static&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Name and description overrides&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, it is static&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Selection by route or header&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, if it is stable per agent&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/mcp/proxy&lt;/code> and virtual tools&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, the surface is fixed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Semantic filter&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Yes, it rewrites the block per request&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>When it does act, the filter has details you need to know before enabling it. It embeds only each tool&amp;rsquo;s description, with the name as a fallback if it is missing; the parameter schema does not go in. Of the query it embeds &lt;strong>only the last user message&lt;/strong>: on turn twelve of an agent conversation, that might be &amp;ldquo;yes, go ahead&amp;rdquo;. The tool embeddings are cached and indexed at start-up; the query&amp;rsquo;s is computed on every request, with a synchronous call inside a coroutine.&lt;/p>
&lt;p>Its failure modes are almost all open, which is the reasonable thing: if the embedding model does not respond, if nothing passes the threshold, if the names do not match the catalogue, it returns all the tools. The exception is context overflow, which fails closed with a 400, and which if it happens while building the index during start-up is memoised and makes &lt;strong>all&lt;/strong> subsequent requests fail until you restart. The defaults are &lt;code>text-embedding-3-small&lt;/code>, &lt;code>top_k&lt;/code> of 10 and a threshold of 0.3, under &lt;code>litellm_settings.mcp_semantic_tool_filter&lt;/code>.&lt;/p>
&lt;p>&lt;strong>&lt;code>/mcp/proxy&lt;/code> does serve external clients&lt;/strong>, and that is where its real value lies. It replaces the catalogue with three tools with opaque identifiers. How &lt;code>search_tools&lt;/code> searches deserves a line, because it is not what you would assume: by default &lt;strong>it is neither a regular expression nor a semantic match, it is a keyword counter&lt;/strong>, how many tokens of the query appear as a substring in the concatenated name and description. It only moves to embeddings if you configure a model in &lt;code>mcp_tool_search&lt;/code>. The result cap is hard-coded at five in this mode, and the client cannot ask for more. In exchange, &lt;code>call_tool&lt;/code> goes through the same permission checks as a normal call plus a validation of the argument schema that the normal route does not do at that point.&lt;/p>
&lt;p>There is a related variant: with &lt;code>mcp_tool_search_enabled&lt;/code> in the key&amp;rsquo;s permissions, the catalogue is replaced by four virtual tools, and there the search one does expose &lt;code>top_k&lt;/code> to the client.&lt;/p>
&lt;p>&lt;strong>The catalogue order is not deterministic, and that breaks the client&amp;rsquo;s cache without anybody filtering anything.&lt;/strong> The list of allowed servers comes from iterating a Python set of strings, so with the random hash seed the order differs between processes, between uvicorn workers and between restarts. There is no sorting anywhere in the listing path, and there is no tool cache: every &lt;code>tools/list&lt;/code> queries the upstreams live, so the order within each server also depends on what the upstream returns that time. The gateway does not offer today the guarantee that the July specification recommends. The fix, sorting the catalogue before serving it, fits on one line.&lt;/p>
&lt;p>Nor is there any pagination towards the client. The catalogue is returned whole, always.&lt;/p>
&lt;p>&lt;strong>And the lever almost nobody uses is the static one.&lt;/strong> Every MCP server accepts &lt;code>tool_name_to_display_name&lt;/code> and &lt;code>tool_name_to_description&lt;/code>, and those overrides &lt;strong>really do replace what is sent to the client&lt;/strong> in &lt;code>tools/list&lt;/code>, not just what the administrator sees in the interface. The &lt;code>tools/call&lt;/code> routing translates from the new name to the real one before any permission check. With that you can trim a four-hundred-token description written by a third party, rename an ambiguous tool so it does not compete with a similar one, and do it without touching the prompt cache, because it is static and holds for all turns.&lt;/p>
&lt;p>It is also the rug pull mitigation left pending from the security section: if the description the model sees is the one the operator wrote, whatever the upstream server changes stops reaching the context. Two limits. They are configured through the database or the management API, &lt;strong>not through &lt;code>config.yaml&lt;/code>&lt;/strong>. And the reverse name translation is by exact equality and is not disambiguated between servers, so two servers with the same display name route unstably, made worse by the non-deterministic order of the previous paragraph.&lt;/p>
&lt;p>Alongside that, per-server &lt;code>allowed_tools&lt;/code> remains the blunt and effective instrument: it filters the listing the client sees, reduces tokens, and is static.&lt;/p>
&lt;h3 id="the-ladder-in-order">The ladder, in order&lt;/h3>
&lt;ol>
&lt;li>&lt;strong>Trim.&lt;/strong> Per-server &lt;code>allowed_tools&lt;/code>, plus a per-key allowlist. Cheap, static, does not break the cache and reduces the permission surface at the same time.&lt;/li>
&lt;li>&lt;strong>Rewrite the descriptions&lt;/strong> with the overrides. That is where the evidence is, because the description carries weight and the name almost none, and along the way it pins the contract against the rug pull.&lt;/li>
&lt;li>&lt;strong>Split by route.&lt;/strong> Each agent to &lt;code>/{server}/mcp&lt;/code> or to a tool set, never to the aggregated endpoint. Stable per agent, so the cache survives.&lt;/li>
&lt;li>&lt;strong>Sort the catalogue&lt;/strong> before serving it if you control the deployment, while the gateway does not do it.&lt;/li>
&lt;li>&lt;strong>&lt;code>/mcp/proxy&lt;/code>&lt;/strong> when the catalogue goes past thirty or fifty tools. It is progressive disclosure and it works with external clients, which is more than you can say for the semantic filter.&lt;/li>
&lt;li>&lt;strong>The semantic filter&lt;/strong>, only when the one calling the model is LiteLLM. With a generous &lt;code>top_k&lt;/code>, knowing that it embeds only the last user message and that context overflow is a closed and sticky failure.&lt;/li>
&lt;li>&lt;strong>What not to do&lt;/strong>: a home-grown top-K filter that rewrites the &lt;code>tools&lt;/code> array on every turn. It costs the whole prefix and, according to four of the five measured strategies, probably makes selection worse.&lt;/li>
&lt;/ol>
&lt;h2 id="the-declared-cost-which-is-worth-zero">The declared cost, which is worth zero&lt;/h2>
&lt;p>The calculation has four precedence steps: cost set by a post-call hook, per-tool cost in &lt;code>tool_name_to_cost_per_query&lt;/code>, server default cost, and &lt;strong>0.0&lt;/strong>.&lt;/p>
&lt;p>Without configuring &lt;code>mcp_server_cost_info&lt;/code>, all MCP traffic is accounted at zero and consumes neither key nor team budget.&lt;/p>
&lt;p>There are exactly two Prometheus metrics for MCP:&lt;/p>
&lt;ul>
&lt;li>&lt;code>litellm_mcp_tool_calls_total&lt;/code>&lt;/li>
&lt;li>&lt;code>litellm_mcp_tool_call_spend_metric&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>Both with the same eight labels: tool name, server name, key hash, key alias, team, team alias, user and end user.&lt;/p>
&lt;p>And the second is only incremented if the cost is greater than zero. With the default configuration, &lt;strong>that time series never appears in Prometheus&lt;/strong>. It is not that it is worth zero: it is that it does not exist.&lt;/p>
&lt;p>Nor is there a &lt;code>tools/list&lt;/code> metric, or MCP latency, or MCP errors, or upstream server health. For that you are left with the generic request metrics.&lt;/p>
&lt;p>In the reports, &lt;code>/spend/calculate&lt;/code> has no MCP branch: it accepts a model and messages, or a completion response, and it always calls the LLM cost calculation. The per-team report does include MCP calls, grouped by the &lt;code>model&lt;/code> column, which for these rows is worth &lt;code>MCP: tool_name&lt;/code>. That is: every tool appears as if it were a model, with zero input and output tokens. Only the sessions endpoint separates the two call types explicitly.&lt;/p>
&lt;h2 id="observability-and-the-trace-that-gets-cut">Observability, and the trace that gets cut&lt;/h2>
&lt;p>There are two OpenTelemetry stacks in LiteLLM and which one runs depends on an environment variable.&lt;/p>
&lt;p>Without &lt;code>LITELLM_OTEL_V2=true&lt;/code>, the old logger runs, and it has no MCP handling: the only appearance of MCP in the whole file is the metadata key in a list. The tool call comes out as a generic &lt;code>litellm_request&lt;/code> span with the model set to &lt;code>MCP: name&lt;/code>.&lt;/p>
&lt;p>With v2 enabled there is a dedicated span, with a dedicated role, client kind and a parent in the proxy&amp;rsquo;s request span. The span name is &lt;code>tools/call tool-name&lt;/code>. The attributes are correct and follow the conventions:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>gen_ai.operation.name&lt;/code>&lt;/td>
&lt;td>&lt;code>execute_tool&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.method.name&lt;/code>&lt;/td>
&lt;td>&lt;code>tools/call&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.session.id&lt;/code>&lt;/td>
&lt;td>MCP session identifier&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.name&lt;/code>&lt;/td>
&lt;td>Tool name&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.call.arguments&lt;/code>&lt;/td>
&lt;td>Only with content capture enabled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.call.result&lt;/code>&lt;/td>
&lt;td>Only with content capture enabled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>server.address&lt;/code>, &lt;code>server.port&lt;/code>&lt;/td>
&lt;td>From the upstream URL, redacted&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm.cost.total&lt;/code>&lt;/td>
&lt;td>The cost that is probably worth zero&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Content capture is off by default. Without it you see neither the arguments nor the result in the trace.&lt;/p>
&lt;p>A note for anyone working with semantic conventions: &lt;code>mcp.tool.name&lt;/code> &lt;strong>does not exist&lt;/strong>. The MCP registry has exactly four attributes (&lt;code>mcp.method.name&lt;/code>, &lt;code>mcp.session.id&lt;/code>, &lt;code>mcp.protocol.version&lt;/code> and &lt;code>mcp.resource.uri&lt;/code>), all four in development, and the tool name goes in &lt;code>gen_ai.tool.name&lt;/code>. LiteLLM gets it right. And the GenAI conventions were extracted into their own repository which as of today &lt;strong>has no release at all&lt;/strong>, so there is no version number to cite.&lt;/p>
&lt;p>In Langfuse, the vendor mapper returns an empty dictionary for any span that is not an LLM call. An MCP call appears as a raw span with its attributes, with no native input, output or model fields.&lt;/p>
&lt;h3 id="the-trace-is-cut-at-the-gateway">The trace is cut at the gateway&lt;/h3>
&lt;p>Incoming context is read. The &lt;code>traceparent&lt;/code> the client puts in &lt;code>params._meta&lt;/code>, following the specification&amp;rsquo;s propagation proposal, is extracted and turned into a &lt;strong>link&lt;/strong> on the span, never into its parent. The reason is documented in the code: parenting to the remote trace would leave the span hanging off a trace whose root never reaches the backend. The client&amp;rsquo;s baggage is discarded on purpose to avoid forging identity attributes.&lt;/p>
&lt;p>Outgoing context is not propagated. There is no propagator injection call anywhere in the tree. The headers that go out towards the upstream MCP server are built with authentication and the authorised extra headers, and nothing else.&lt;/p>
&lt;p>The operational consequence: there is no end-to-end trace. If the upstream MCP server is instrumented, its spans live in another trace and there is no way to correlate them automatically.&lt;/p>
&lt;h2 id="auditing-and-the-conflict-with-privacy">Auditing and the conflict with privacy&lt;/h2>
&lt;p>A tool call leaves a normal spend row, with call type &lt;code>call_mcp_tool&lt;/code>, spend equal to the cost per query, model &lt;code>MCP: name&lt;/code>, zero tokens in the three token columns, a dedicated &lt;code>mcp_namespaced_tool_name&lt;/code> column, and the usual key, team, user and session fields.&lt;/p>
&lt;p>And in the &lt;code>metadata&lt;/code> column, in JSON, the complete structure of the call: name, &lt;strong>arguments&lt;/strong>, result, server, namespaced name, MCP session identifier, authentication mode and server resource with the URL redacted.&lt;/p>
&lt;h3 id="the-problem">The problem&lt;/h3>
&lt;p>That structure is written into the metadata column &lt;strong>without passing through the privacy switch&lt;/strong>. The assignment is direct and is not conditioned by the function that governs whether prompts and responses are stored, which does control the &lt;code>messages&lt;/code> and &lt;code>response&lt;/code> columns.&lt;/p>
&lt;p>And the redaction function does not cover it either: it only touches &lt;code>messages&lt;/code> and &lt;code>response&lt;/code>. A search for MCP in the redaction file returns nothing.&lt;/p>
&lt;p>The practical result: an operator who enables &lt;code>turn_off_message_logging&lt;/code> for GDPR keeps sending the full arguments of every tool to every logging destination they have configured. To the SIEM, to Langfuse, to S3.&lt;/p>
&lt;p>If an argument carries a national ID number, an address, a case number or a medical record, it has been exported.&lt;/p>
&lt;p>There is an aggravating factor that does not depend on LiteLLM. The revision of the specification in force introduces an extension that allows mirroring tool arguments into HTTP headers visible to load balancers, proxies and web application firewalls. The specification recommends not marking sensitive parameters that way, and it is a recommendation, and the upstream server decides.&lt;/p>
&lt;h3 id="the-fit-with-the-ens">The fit with the ENS&lt;/h3>
&lt;p>The conflict is real and has a familiar shape: activity logging (&lt;code>op.exp.8&lt;/code>) requires the call to be traced and non-repudiable; protection of personal data (&lt;code>mp.info.1&lt;/code>) requires that log not to become a secondary repository of personal data with a different purpose. The tool argument is simultaneously the evidence of the action and the data. Redacting it destroys non-repudiation; storing it in the clear creates the repository.&lt;/p>
&lt;p>The defensible way out you can build today: a hash of the argument in the audit log, the argument in the clear with encryption (&lt;code>mp.info.3&lt;/code>), separate retention and its own access control. The hash sustains non-repudiation and the clear text is only opened under procedure.&lt;/p>
&lt;p>There are two other measures this material touches directly. Change management (&lt;code>op.exp.5&lt;/code>) because registering an MCP server and, above all, changing the description of an already approved tool are changes to the security configuration. And maintenance and updates (&lt;code>op.exp.4&lt;/code>) because, as you can see in the next section, an old version of the gateway is a non-compliance with a name and a number.&lt;/p>
&lt;p>Two honesty warnings. The codes follow the numbering of Annex II of RD 311/2022 and you have to check them against the text in force before citing them in a compliance document. And on NIS2: as of the date of this post the directive &lt;strong>is still not transposed in Spain&lt;/strong>, with the draft bill on cybersecurity coordination and governance going through the process and a reasoned opinion from the Commission over the delay. What is enforceable today in the public sector and its suppliers is the ENS.&lt;/p>
&lt;h3 id="what-is-not-logged">What is not logged&lt;/h3>
&lt;p>An administrative gap: registering, modifying and deleting MCP servers &lt;strong>generates no audit log&lt;/strong>. There are three &lt;code>TODO&lt;/code> comments in the management code that say so, one of them with an &lt;code>if is_audit_logging_enabled(): pass&lt;/code>. In the same deletion block there are two more TODOs about not cleaning up orphaned permissions on keys and teams.&lt;/p>
&lt;h2 id="security">Security&lt;/h2>
&lt;h3 id="the-vulnerability-you-need-to-know-about">The vulnerability you need to know about&lt;/h3>
&lt;p>&lt;code>CVE-2026-42271&lt;/code>, published on 8 May 2026. Affects LiteLLM from 1.74.2 up to versions prior to 1.83.7. CVSS 3.1 of 8.8.&lt;/p>
&lt;p>The &lt;code>POST /mcp-rest/test/connection&lt;/code> and &lt;code>POST /mcp-rest/test/tools/list&lt;/code> endpoints, which exist to preview an MCP server before saving it, accepted the stdio transport&amp;rsquo;s &lt;code>command&lt;/code>, &lt;code>args&lt;/code> and &lt;code>env&lt;/code> in the request body. A low-privilege key executed arbitrary commands on the proxy host. It also affects Red Hat OpenShift AI on several branches.&lt;/p>
&lt;p>It is the story of this post condensed into one line: the gateway you put in to contain MCP was the hole.&lt;/p>
&lt;p>It does not come alone. The 1.83 branch concentrates three more serious ones:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>CVE&lt;/th>
&lt;th>Component&lt;/th>
&lt;th>CVSS&lt;/th>
&lt;th>Fixed in&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>CVE-2026-42208&lt;/code>&lt;/td>
&lt;td>SQL injection in key validation, &lt;strong>with no prior authentication&lt;/strong>&lt;/td>
&lt;td>9.8&lt;/td>
&lt;td>1.83.7&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>CVE-2026-35030&lt;/code>&lt;/td>
&lt;td>OIDC userinfo cache keyed on &lt;code>token[:20]&lt;/code>, collision and impersonation&lt;/td>
&lt;td>9.1&lt;/td>
&lt;td>1.83.0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>CVE-2026-40217&lt;/code>&lt;/td>
&lt;td>Remote execution through bytecode rewriting in the guardrails test endpoint&lt;/td>
&lt;td>8.8&lt;/td>
&lt;td>no version recorded&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Plus two privilege escalations fixed in 1.83.10 and 1.83.14.&lt;/p>
&lt;p>The operational conclusion is short: &lt;strong>a deployment below 1.83.14 is exposed to remote execution or to an unauthenticated SQL injection&lt;/strong>. The current branch is 1.102.0.&lt;/p>
&lt;p>And a note for context: the whole MCP ecosystem has the same pattern. The unauthenticated remote execution in MCP Inspector, the command injection in &lt;code>mcp-remote&lt;/code> when connecting to an untrusted server, DNS rebinding disabled by default in the Python and TypeScript SDKs until late 2025, the directory escapes in the reference servers. Connecting to an untrusted MCP server compromises the client, not just the other way round.&lt;/p>
&lt;h3 id="the-rug-pull-nobody-covers">The rug pull nobody covers&lt;/h3>
&lt;p>Documented since April 2025: a server can change a tool&amp;rsquo;s description &lt;strong>after&lt;/strong> the client has approved it. The description is what the model reads to decide when and how to use it, so changing it is changing the behaviour without asking for permission again.&lt;/p>
&lt;p>The specification in force does not address it. It defines list change notification and cache freshness fields, which are recency mechanisms, not integrity ones. The closest normative warning is that clients must treat tool annotations as untrusted unless they come from trusted servers.&lt;/p>
&lt;p>LiteLLM does not cover it either. The only &amp;ldquo;pinning&amp;rdquo; in the code is of the configuration&amp;rsquo;s server identifier, so that it does not change when you edit the YAML. There is no hash or comparison of the description or the input schema between successive listings. A search for related terms in the MCP module: nothing functional.&lt;/p>
&lt;p>The control exists outside the gateway. &lt;code>mcp-scan&lt;/code>, from the same researchers who documented the attack, implements description hash pinning precisely to detect this.&lt;/p>
&lt;p>In ENS terms it is an unauthorised change to the security configuration that today goes undetected. And the home-made fix fits in a cron job: store the hash of the description and schema of every approved tool, compare against &lt;code>tools/list&lt;/code> periodically, alert on the difference.&lt;/p>
&lt;h3 id="three-more-holes-in-the-code">Three more holes in the code&lt;/h3>
&lt;p>&lt;strong>No URL validation towards the upstream.&lt;/strong> A new server&amp;rsquo;s &lt;code>url&lt;/code> field has no validator. The SSRF protection utilities exist in the tree and are used in OAuth discovery and in downloading OpenAPI specifications, but &lt;strong>not&lt;/strong> in the egress of &lt;code>tools/call&lt;/code>. A server pointing at a private range or at the cloud metadata IP is accepted as is.&lt;/p>
&lt;p>&lt;strong>The stdio transport is local execution on the proxy host.&lt;/strong> There is a command allowlist: &lt;code>npx&lt;/code>, &lt;code>uvx&lt;/code>, &lt;code>python&lt;/code>, &lt;code>python3&lt;/code>, &lt;code>node&lt;/code>, &lt;code>docker&lt;/code>, &lt;code>deno&lt;/code>, extendable through an environment variable. The code&amp;rsquo;s own comment admits the residual risk: the permitted runtimes can execute code through arguments. With &lt;code>docker&lt;/code> and &lt;code>npx&lt;/code> in there, it is de facto arbitrary execution for any proxy administrator.&lt;/p>
&lt;p>&lt;strong>Two server modes skip the gateway&amp;rsquo;s authentication.&lt;/strong> Pass-through and authentication delegation to the upstream completely omit virtual key validation. They are well hardened (fail-closed if the target is mixed or unresolvable, strict comparison against truthy values, explicit blocking of the client credentials flow so as not to lend out the proxy&amp;rsquo;s service account), and the result is still that those servers have no gateway access control.&lt;/p>
&lt;h3 id="the-limit-that-truncates-in-silence">The limit that truncates in silence&lt;/h3>
&lt;p>The &lt;code>tools/list&lt;/code> pagination stops and returns what it has accumulated in three cases: repeated cursor, one thousand pages, or expiry of a global deadline. The docstring declares it: a slow or faulty upstream produces a partial catalogue instead of an error.&lt;/p>
&lt;p>The first two log a warning. The third, the deadline, exits &lt;strong>without logging anything at all&lt;/strong>. A slow server makes part of its tools disappear from the catalogue with no trace left behind.&lt;/p>
&lt;h3 id="correction-on-the-slot-leak">Correction on the slot leak&lt;/h3>
&lt;p>In the previous post I took issue 34534 at face value, according to which every MCP tool call acquires a &lt;code>max_parallel_requests&lt;/code> slot and does not release it. Tracing the 1.102.0 code, that diagnosis no longer holds: the release path exists, on both success and failure, and the limiter is registered as a litellm callback and not just as a proxy hook.&lt;/p>
&lt;p>With one honest caveat: I found no regression test covering the full cycle for MCP, and the definitive confirmation is a real proxy with &lt;code>max_parallel_requests: 2&lt;/code> and five MCP calls in a row. If anybody reproduces it today, I would like to know.&lt;/p>
&lt;h2 id="the-specification-moved-underneath">The specification moved underneath&lt;/h2>
&lt;p>The MCP revision in force is &lt;code>2026-07-28&lt;/code>, published on 28 July 2026, and in between there was another one, &lt;code>2025-11-25&lt;/code>. The changes in the latest one are not cosmetic:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Protocol sessions and the session header are removed.&lt;/strong> Cross-request state becomes an explicit identifier in the tool arguments.&lt;/li>
&lt;li>&lt;strong>The initialisation handshake is removed.&lt;/strong> Every request carries version and capabilities in &lt;code>_meta&lt;/code>. MCP becomes stateless.&lt;/li>
&lt;li>&lt;code>server/discover&lt;/code> appears as a mandatory call.&lt;/li>
&lt;li>Elicitation changes shape: out go server-initiated requests, in comes a multi-round pattern where the server returns an &amp;ldquo;input required&amp;rdquo; result and the client retries with the answers.&lt;/li>
&lt;li>&lt;code>ping&lt;/code>, &lt;code>logging/setLevel&lt;/code> and the roots change notification are removed. SSE stream resumption is removed.&lt;/li>
&lt;li>Standard HTTP headers are mandatory on POST, which lets a gateway or a web application firewall decide without parsing the JSON.&lt;/li>
&lt;li>On authorisation: mandatory issuer validation in the client, and &lt;strong>dynamic client registration is deprecated&lt;/strong> in favour of client identifier metadata documents.&lt;/li>
&lt;li>Roots, sampling and logging are deprecated, with a minimum window of twelve months.&lt;/li>
&lt;/ul>
&lt;p>LiteLLM 1.102.0 advertises &lt;code>2025-06-18&lt;/code>. Its version enumeration has three values and none of them is later than that date. The literal is sent in the &lt;code>initialize&lt;/code> against the upstream.&lt;/p>
&lt;p>One nuance that is not entirely bad: the version the gateway advertises to &lt;strong>its&lt;/strong> clients is not hard-coded in LiteLLM, the SDK negotiates it. And there is partial implementation of the intermediate revision, because the elicitation and sampling handlers reference it in their docstrings. That is: partial implementation of one revision while advertising an earlier one.&lt;/p>
&lt;p>In practical interoperability it does not hurt yet, because the client ecosystem is still mostly on the 2025 revisions. It hurts in planning: July&amp;rsquo;s stateless redesign invalidates the gateway&amp;rsquo;s session model, and that is work ahead.&lt;/p>
&lt;p>Two ecosystem notes for anyone choosing. There are clients that speak remote streamable HTTP natively (Claude and Claude Code, ChatGPT with OAuth and dynamic registration, VS Code with Copilot, Cursor, Zed, Kiro) and others that still demand stdio or a local bridge with &lt;code>mcp-remote&lt;/code>, among them Windsurf, Cline and Continue.&lt;/p>
&lt;h2 id="tool-execution-from-the-proxy-itself">Tool execution from the proxy itself&lt;/h2>
&lt;p>Besides serving MCP outwards, the proxy can consume it itself. It is enabled with an &lt;code>mcp&lt;/code> block in the body, with &lt;code>server_url&lt;/code> equal to the &lt;code>litellm_proxy&lt;/code> sentinel, and it works both on &lt;code>/v1/responses&lt;/code> and on &lt;code>/chat/completions&lt;/code>.&lt;/p>
&lt;p>The flow: it separates the gateway&amp;rsquo;s tools, lists them, filters by allowed, deduplicates, translates them into OpenAI function format and concatenates them. It calls the model. If there are tool calls and auto-execution is permitted, it runs them against the MCP servers. It builds the follow-up and makes &lt;strong>one&lt;/strong> second call.&lt;/p>
&lt;p>Two limits you need to know before designing on top of this.&lt;/p>
&lt;p>&lt;strong>The loop is a single hop.&lt;/strong> There is no multi-step iteration. The calls from the first response are executed and the follow-up is returned. A plan that chains tools requires the client to call again.&lt;/p>
&lt;p>&lt;strong>Auto-execution fails closed and as a block.&lt;/strong> It requires all MCP references to have approval set to &amp;ldquo;never&amp;rdquo;; a single one requiring approval disables auto-execution for the whole request.&lt;/p>
&lt;p>And a third one, which is the one that bites: &lt;strong>the multi-turn state of &lt;code>/v1/responses&lt;/code> is rehydrated from the spend table&lt;/strong>. The query is literal, it looks up the session of the previous &lt;code>request_id&lt;/code> and retrieves all the rows of that session ordered by time.&lt;/p>
&lt;p>With &lt;code>disable_spend_logs: true&lt;/code> nothing is written, the query comes out empty and the history is lost &lt;strong>in silence&lt;/strong>, with no 4xx error. The code itself acknowledges it: it does not expect retries because deployments that do not write spend logs have nothing to wait for. It is a coupling between a performance option and a conversation feature that nobody expects to find.&lt;/p>
&lt;h2 id="health-and-day-to-day-operation">Health and day-to-day operation&lt;/h2>
&lt;p>Health checks exist, they are on demand and they are not periodic. &lt;code>GET /v1/mcp/server/health&lt;/code> accepts repeated identifiers and returns per-server status. The implementation opens a client and runs an empty operation inside the session, with a ten-second timeout.&lt;/p>
&lt;p>It skips the check entirely if the server requires per-user authentication, and those always return unknown status.&lt;/p>
&lt;p>The surprising part: the status, last check and error columns have existed in the database since a May 2025 migration, and the check function &lt;strong>returns an object without writing to the database&lt;/strong>. Status is recalculated on every request. There is no periodic MCP health job.&lt;/p>
&lt;p>When an upstream server is down, the behaviour is to degrade and not block. The listing fans out in parallel and each failing server contributes an empty list plus a result classified into six categories. Those results reach the client in the response&amp;rsquo;s &lt;code>_meta&lt;/code> under a vendor key, without filtering the upstream&amp;rsquo;s text or URL. The reason for the design is in the code: an empty contribution with no signal makes a broken upstream indistinguishable from a healthy server with no tools.&lt;/p>
&lt;p>There is no cache of the previous list. A server that is down disappears from that turn&amp;rsquo;s tools.&lt;/p>
&lt;p>On registering and deregistering without restarting, there are two worlds. Database servers can be created, edited and deleted through the API, and every operation reloads the registry with an atomic swap. Propagation between replicas goes through Redis publication on the configuration change channel, if there is Redis, and through polling every 30 seconds if there is not.&lt;/p>
&lt;p>Servers declared in &lt;code>config.yaml&lt;/code> are loaded once, at start-up. There is no re-read. Changing them requires a restart.&lt;/p>
&lt;p>And there is a precedence trap between the two worlds, with two different warnings in the code. A database row with the same identifier as a configuration entry &lt;strong>hides the configuration one entirely&lt;/strong>. And the other way round, a configuration identifier that matches the name or alias of a database server captures its permissions, because identifiers are resolved before names. Both cases emit a log warning, once.&lt;/p>
&lt;h2 id="a-hardened-configyaml">A hardened &lt;code>config.yaml&lt;/code>&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Access is not open by default, but this closes the empty-key&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># inheritance towards the team, which is the one that surprises people.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">require_key_mcp_access_defined&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">require_end_user_mcp_access_defined&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Without this, allow_all_keys overrides the key&amp;#39;s explicit scope.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mcp_allow_all_keys_respects_mcp_scope&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Does not cover MCP arguments. See the auditing section.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">turn_off_message_logging&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">mcp_servers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">inventario&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://mcp-inventario.interna.svc/mcp&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">transport&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">auth_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bearer_token&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">auth_value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/MCP_INVENTARIO_TOKEN&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Explicit allowlist. Also enforced on tools/call.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allowed_tools&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">buscar_articulo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">consultar_stock&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Never true on a server with data.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allow_all_keys&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">access_groups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;operaciones&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_concurrent_requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mcp_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Inventario de almacen, solo lectura&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Without this, MCP spend is zero and its metric does not exist.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mcp_server_cost_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">default_cost_per_query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0005&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tool_name_to_cost_per_query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">consultar_stock&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0002&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And in the process environment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Without this there is no MCP span, only a generic litellm_request.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_OTEL_V2&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Full block hashes, not truncated, if correlating&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># with engine events.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_MCP_CLIENT_TIMEOUT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">45&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_MCP_TOOL_LISTING_TIMEOUT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">20&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For clients, the rule is not to use the aggregated endpoint. Each agent points at &lt;code>/{server}/mcp&lt;/code> or at a tool set, or at the three-tool proxy mode if the catalogue is large.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Check the version. Below 1.83.14 there is remote execution and an unauthenticated SQL injection with an assigned CVE.&lt;/li>
&lt;li>Inventory which servers have &lt;code>allow_all_keys&lt;/code> and enable the setting that makes it respect the key&amp;rsquo;s scope.&lt;/li>
&lt;li>Audit the access groups separately, because they are additive on top of the key and team ceiling.&lt;/li>
&lt;li>Declare in writing the fail-open behaviour on tool permission resolution errors.&lt;/li>
&lt;li>Measure the real cost: number of tools per server from the listing row, and &lt;code>tools&lt;/code> block tokens from the stored request body.&lt;/li>
&lt;li>Choose a catalogue strategy before connecting the third server: three-tool proxy mode, semantic filter, or selection by route. Not the aggregated endpoint.&lt;/li>
&lt;li>Declare &lt;code>mcp_server_cost_info&lt;/code> even with a token value, or the spend metric will not exist.&lt;/li>
&lt;li>Enable the OTel v2 logger, and decide deliberately whether to enable content capture.&lt;/li>
&lt;li>Assume the trace is cut at the gateway and plan correlation with the upstream by other means.&lt;/li>
&lt;li>Treat the spend table&amp;rsquo;s metadata column as a personal data repository: encryption, its own retention and separate access control.&lt;/li>
&lt;li>Set up the rug pull check, because nobody does it: hash of the description and schema of every approved tool, periodic comparison against the listing.&lt;/li>
&lt;li>Restrict egress towards MCP servers at the network level, because the gateway does not validate the destination URL.&lt;/li>
&lt;li>Do not enable the stdio transport unless you need it, and if you do, review what is on the command allowlist.&lt;/li>
&lt;li>If you use &lt;code>/v1/responses&lt;/code> with multi-turn state, do not enable &lt;code>disable_spend_logs&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>MCP access is not open by default in 1.102.0.&lt;/strong> I got that wrong in the previous post. What opens things up is per-server &lt;code>allow_all_keys&lt;/code>.&lt;/li>
&lt;li>&lt;strong>&lt;code>allow_all_keys&lt;/code> overrides the key&amp;rsquo;s explicit scope&lt;/strong> unless you enable a setting that ships disabled.&lt;/li>
&lt;li>&lt;strong>&lt;code>require_key_mcp_access_defined&lt;/code> only closes key-to-team inheritance.&lt;/strong> Access group grants remain additive.&lt;/li>
&lt;li>&lt;strong>On an indeterminate failure resolving tool permissions, the code fails open&lt;/strong> for keys and JWTs.&lt;/li>
&lt;li>&lt;strong>The default cost of an MCP call is 0.0&lt;/strong>, and the spend metric is only emitted if the cost is greater than zero, so the series does not exist.&lt;/li>
&lt;li>&lt;strong>MCP calls appear in the per-team report as if they were models&lt;/strong>, named &lt;code>MCP: tool&lt;/code> and with zero tokens.&lt;/li>
&lt;li>&lt;strong>The OTel logger with MCP support is off by default.&lt;/strong> Without the environment variable there is no MCP span.&lt;/li>
&lt;li>&lt;strong>Trace context is not propagated towards the upstream MCP server.&lt;/strong> There is no end-to-end trace.&lt;/li>
&lt;li>&lt;strong>&lt;code>mcp.tool.name&lt;/code> does not exist&lt;/strong> in the semantic conventions. The name goes in &lt;code>gen_ai.tool.name&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Message redaction does not cover tool arguments.&lt;/strong> They are written in the clear into the spend table.&lt;/li>
&lt;li>&lt;strong>Registering, modifying and deleting MCP servers generates no audit log.&lt;/strong> There are three TODOs in the code.&lt;/li>
&lt;li>&lt;strong>The semantic tool filter is not applied on &lt;code>tools/list&lt;/code>.&lt;/strong> Only on &lt;code>/chat/completions&lt;/code> and &lt;code>/v1/responses&lt;/code>.&lt;/li>
&lt;li>&lt;strong>The catalogue order is not deterministic&lt;/strong>, because it comes from iterating a set of strings. It breaks the client&amp;rsquo;s prompt cache even though nobody filters anything.&lt;/li>
&lt;li>&lt;strong>&lt;code>search_tools&lt;/code> in proxy mode counts keywords by default&lt;/strong>, it uses neither regular expressions nor embeddings, and its cap of five results is hard-coded.&lt;/li>
&lt;li>&lt;strong>Description overrides really do replace what the client sees&lt;/strong>, and they are the only catalogue lever that does not break the cache. They are only configurable through the database or the API.&lt;/li>
&lt;li>&lt;strong>&lt;code>spec_version&lt;/code> no longer exists&lt;/strong> in the configuration schema.&lt;/li>
&lt;li>&lt;strong>&lt;code>/mcp/sse&lt;/code> does not use the SSE transport&lt;/strong>, and the messages endpoint its object advertises is dead code.&lt;/li>
&lt;li>&lt;strong>Listing pagination truncates in silence when the deadline expires&lt;/strong>, without leaving a single log line.&lt;/li>
&lt;li>&lt;strong>A database row hides a configuration entry with the same identifier&lt;/strong>, and the other way round a configuration identifier can capture the permissions of a database server.&lt;/li>
&lt;li>&lt;strong>Servers declared in &lt;code>config.yaml&lt;/code> require a restart.&lt;/strong> Only the database ones reload hot.&lt;/li>
&lt;li>&lt;strong>The health check does not write its result to the database&lt;/strong> even though the columns exist, and there is no periodic job.&lt;/li>
&lt;li>&lt;strong>The gateway does not validate the upstream MCP server&amp;rsquo;s URL.&lt;/strong> The anti-SSRF utilities exist and are not used on that path.&lt;/li>
&lt;li>&lt;strong>&lt;code>docker&lt;/code> and &lt;code>npx&lt;/code> are on the stdio command allowlist.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The &lt;code>/v1/responses&lt;/code> tool loop is a single hop&lt;/strong>, and &lt;code>disable_spend_logs: true&lt;/code> breaks its multi-turn behaviour with no error.&lt;/li>
&lt;li>&lt;strong>LiteLLM advertises the June 2025 revision of the protocol&lt;/strong>, two behind the one in force.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>There is a thread running through the six posts in this track that is more visible here than anywhere else. The gateway is chosen for one job and ends up doing five, and each new job arrives with its own defaults, which are almost never the ones you would have chosen.&lt;/p>
&lt;p>In the case of MCP the asymmetry is twofold. On one side, the part that looks dangerous is better built than you would assume: access control is enforced on execution and not just on the listing, the hierarchy is built out of intersections, header forwarding is an allowlist, and default access is closed, contrary to what I myself wrote two days ago. On the other, the part nobody looks at is worse: tool arguments come out in the clear bypassing the privacy switch, spend is worth zero and its metric never gets to exist, the trace is cut, and a change to the description of an approved tool goes undetected by anybody in the whole chain, neither the specification nor the gateway.&lt;/p>
&lt;p>The architecture decision that comes out of this is that the MCP door has to be treated as what it is, an interconnection of systems with third parties, and not as one more function of the LLM proxy. That means its own inventory, formal registration, change control over every tool&amp;rsquo;s description, network isolation towards the upstream and a log designed knowing that it is going to contain personal data.&lt;/p>
&lt;p>And it means accepting that the cost you really pay for connecting fifteen MCP servers is in no column of the spend table. It is spread across the &lt;code>prompt_tokens&lt;/code> of every model call, on every turn, and across the accuracy you lose when the catalogue is too large for the model to choose well. That number can be computed with what the gateway already stores. The first step is wanting to compute it.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a> — the other face of the same problem, when the agent is a traffic class and not an identity.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing&lt;/a> — the sibling post, where the correction is about what the gateway does not do.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a> — the identity hierarchy these permissions rest on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the trace path that MCP spans reach, or do not reach.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a> — the identity of your own MCP servers, upstream of this gateway.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its deep observability&lt;/a> — the protocol, the six primitives and the semantic conventions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents&lt;/a> — the network isolation this post assumes is necessary and does not explain.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for the ENS, 42001 and the AI Act&lt;/a> — the compliance framework that activity logging and change management fit into.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Secret hardening in a sovereign LLM stack&lt;/a> — the upstream credentials the gateway holds.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">The second cost vector of AI agents&lt;/a> — what a tool loop that half fails costs you.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the other face of the tool catalogue, the one paid in proxy CPU when the token counter serialises it on every request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a> — the seams with the identity provider and with the trace backend.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> — the two standards the MCP specification requires and the authorisation server does not implement.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a> — the resource side: why the official SDKs do not validate the audience and what replaces token exchange.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>MCP Gateway&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/mcp">https://docs.litellm.ai/docs/mcp&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código: &lt;code>litellm/proxy/_experimental/mcp_server/server.py&lt;/code>, &lt;code>mcp_server_manager.py&lt;/code>, &lt;code>rest_endpoints.py&lt;/code>, &lt;code>auth/user_api_key_auth_mcp.py&lt;/code>, &lt;code>cost_calculator.py&lt;/code>, &lt;code>faults/list_outcomes.py&lt;/code>, &lt;code>litellm/proxy/management_endpoints/mcp_management_endpoints.py&lt;/code>, &lt;code>litellm/proxy/spend_tracking/spend_tracking_utils.py&lt;/code>, &lt;code>litellm/litellm_core_utils/redact_messages.py&lt;/code>, &lt;code>litellm/integrations/otel/&lt;/code>, &lt;code>litellm/responses/litellm_completion_transformation/session_handler.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 34534, fuga de slot de concurrencia en llamadas MCP: &lt;a href="https://github.com/BerriAI/litellm/issues/34534">https://github.com/BerriAI/litellm/issues/34534&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-42271&lt;/code>, ejecución de comandos por los endpoints de prueba de MCP: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-42271">https://nvd.nist.gov/vuln/detail/CVE-2026-42271&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-42208&lt;/code>, inyección SQL sin autenticación en la validación de clave: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-42208">https://nvd.nist.gov/vuln/detail/CVE-2026-42208&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-35030&lt;/code>, colisión de clave de caché de userinfo OIDC: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-35030">https://nvd.nist.gov/vuln/detail/CVE-2026-35030&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-49596&lt;/code>, ejecución remota en MCP Inspector: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-49596">https://nvd.nist.gov/vuln/detail/CVE-2025-49596&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-6514&lt;/code>, inyección de comandos en &lt;code>mcp-remote&lt;/code>: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-6514">https://nvd.nist.gov/vuln/detail/CVE-2025-6514&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-66416&lt;/code>, protección de DNS rebinding desactivada por defecto en el SDK de Python: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-66416">https://nvd.nist.gov/vuln/detail/CVE-2025-66416&lt;/a>.&lt;/li>
&lt;li>Invariant Labs, &lt;em>MCP Security Notification: Tool Poisoning Attacks&lt;/em> (tool poisoning, rug pull, shadowing): &lt;a href="https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks">https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks&lt;/a>.&lt;/li>
&lt;li>Invariant Labs, &lt;em>Introducing mcp-scan&lt;/em> (fijado de herramientas por hash): &lt;a href="https://invariantlabs.ai/blog/introducing-mcp-scan">https://invariantlabs.ai/blog/introducing-mcp-scan&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, changelog de la revisión 2026-07-28: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog">https://modelcontextprotocol.io/specification/2026-07-28/changelog&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, changelog de la revisión 2025-11-25: &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/changelog">https://modelcontextprotocol.io/specification/2025-11-25/changelog&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, &lt;em>Security Best Practices&lt;/em> (confused deputy, indicadores de recurso, validación de audiencia): &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices&lt;/a>.&lt;/li>
&lt;li>NSA, &lt;em>Model Context Protocol: Security Design Considerations for AI-Driven Automation&lt;/em>, mayo de 2026: &lt;a href="https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF">https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF&lt;/a>.&lt;/li>
&lt;li>CISA y agencias aliadas, &lt;em>Careful Adoption of Agentic AI Services&lt;/em>, mayo de 2026: &lt;a href="https://www.cisa.gov/resources-tools/resources/careful-adoption-agentic-ai-services">https://www.cisa.gov/resources-tools/resources/careful-adoption-agentic-ai-services&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Advanced tool use&lt;/em> (coste en tokens de las definiciones, búsqueda de herramientas, precisión de selección): &lt;a href="https://www.anthropic.com/engineering/advanced-tool-use">https://www.anthropic.com/engineering/advanced-tool-use&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Code execution with MCP&lt;/em>: &lt;a href="https://www.anthropic.com/engineering/code-execution-with-mcp">https://www.anthropic.com/engineering/code-execution-with-mcp&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Prompt caching&lt;/em> (jerarquía &lt;code>tools&lt;/code> → &lt;code>system&lt;/code> → &lt;code>messages&lt;/code> e invalidación): &lt;a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching">https://platform.claude.com/docs/en/build-with-claude/prompt-caching&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Tool search tool&lt;/em> (herramientas diferidas fuera del prefijo, umbral de 30 a 50 herramientas): &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool">https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool&lt;/a>.&lt;/li>
&lt;li>OpenAI, &lt;em>Prompt caching&lt;/em> (el orden de las herramientas cuenta): &lt;a href="https://developers.openai.com/api/docs/guides/prompt-caching">https://developers.openai.com/api/docs/guides/prompt-caching&lt;/a>.&lt;/li>
&lt;li>OpenAI, &lt;em>Tool search&lt;/em> (las herramientas descubiertas se cargan al final del contexto): &lt;a href="https://developers.openai.com/api/docs/guides/tools-tool-search">https://developers.openai.com/api/docs/guides/tools-tool-search&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Automatic Prefix Caching&lt;/em>: &lt;a href="https://docs.vllm.ai/en/latest/features/automatic_prefix_caching.html">https://docs.vllm.ai/en/latest/features/automatic_prefix_caching.html&lt;/a>.&lt;/li>
&lt;li>Microsoft, &lt;em>Use tools in chat&lt;/em> (límite de 128 herramientas por petición en VS Code): &lt;a href="https://code.visualstudio.com/docs/copilot/agents/agent-tools">https://code.visualstudio.com/docs/copilot/agents/agent-tools&lt;/a>.&lt;/li>
&lt;li>&lt;em>RAG-MCP&lt;/em>, prueba de estrés de 1 a 11.100 servidores candidatos, mayo de 2025: &lt;a href="https://arxiv.org/abs/2505.03275">https://arxiv.org/abs/2505.03275&lt;/a>.&lt;/li>
&lt;li>&lt;em>Benchmarking the Benchmarks&lt;/em>, desalineación del 18,5 % entre etiqueta y realidad, junio de 2026: &lt;a href="https://arxiv.org/html/2607.02577v1">https://arxiv.org/html/2607.02577v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>MCP-Atlas&lt;/em>, taxonomía de fallos sobre 220 herramientas y veinte modelos, febrero de 2026: &lt;a href="https://arxiv.org/html/2602.00933v3">https://arxiv.org/html/2602.00933v3&lt;/a>.&lt;/li>
&lt;li>&lt;em>BiasBusters&lt;/em>, sesgo de selección entre herramientas equivalentes y peso de la descripción frente al nombre: &lt;a href="https://arxiv.org/html/2510.00307">https://arxiv.org/html/2510.00307&lt;/a>.&lt;/li>
&lt;li>&lt;em>MCP Tool Descriptions Are Smelly&lt;/em>, auditoría de 856 herramientas de 103 servidores, febrero de 2026: &lt;a href="https://arxiv.org/html/2602.14878v1">https://arxiv.org/html/2602.14878v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>ToolMenuBench&lt;/em>, seis estrategias de filtrado y 26.460 ejecuciones, junio de 2026: &lt;a href="https://arxiv.org/html/2606.15508">https://arxiv.org/html/2606.15508&lt;/a>.&lt;/li>
&lt;li>&lt;em>Retrieval Models Aren&amp;rsquo;t Tool-Savvy&lt;/em> (ToolRet), corpus de 43.000 herramientas, marzo de 2025: &lt;a href="https://arxiv.org/abs/2503.01763">https://arxiv.org/abs/2503.01763&lt;/a>.&lt;/li>
&lt;li>&lt;em>Tools Are Not Islands&lt;/em>, brecha entre Recall@3 y conjunto completo, julio de 2026: &lt;a href="https://arxiv.org/html/2607.25718">https://arxiv.org/html/2607.25718&lt;/a>.&lt;/li>
&lt;li>&lt;em>ToolFlood&lt;/em>, saturación adversaria de la capa de recuperación, marzo de 2026: &lt;a href="https://arxiv.org/html/2603.13950">https://arxiv.org/html/2603.13950&lt;/a>.&lt;/li>
&lt;li>&lt;em>Scores Are Not Decisions&lt;/em>, el K óptimo como función del coste, julio de 2026: &lt;a href="https://arxiv.org/html/2607.27083v1">https://arxiv.org/html/2607.27083v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>Don&amp;rsquo;t Break the Cache&lt;/em>, caché de prompts en tareas agénticas de horizonte largo, enero de 2026: &lt;a href="https://arxiv.org/html/2601.06007v2">https://arxiv.org/html/2601.06007v2&lt;/a>.&lt;/li>
&lt;li>&lt;em>How Many Tools Should an LLM Agent See? A Chance-Corrected Answer&lt;/em>, mayo de 2026: &lt;a href="https://arxiv.org/html/2605.24660v1">https://arxiv.org/html/2605.24660v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>Semantic Tool Discovery for LLMs&lt;/em>, marzo de 2026: &lt;a href="https://arxiv.org/abs/2603.20313">https://arxiv.org/abs/2603.20313&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, convenciones semánticas de GenAI y MCP: &lt;a href="https://github.com/open-telemetry/semantic-conventions-genai">https://github.com/open-telemetry/semantic-conventions-genai&lt;/a>.&lt;/li>
&lt;li>Boletín Oficial del Estado, Real Decreto 311/2022, Esquema Nacional de Seguridad: &lt;a href="https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191">https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191&lt;/a>.&lt;/li>
&lt;li>IBM ContextForge, gateway y registro MCP: &lt;a href="https://github.com/IBM/mcp-context-forge">https://github.com/IBM/mcp-context-forge&lt;/a>.&lt;/li>
&lt;li>Docker MCP Gateway: &lt;a href="https://github.com/docker/mcp-gateway">https://github.com/docker/mcp-gateway&lt;/a>.&lt;/li>
&lt;li>agentgateway: &lt;a href="https://github.com/agentgateway/agentgateway">https://github.com/agentgateway/agentgateway&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Prefix routing: what LiteLLM does not do, who does, and how much it really improves</title><link>https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/</link><pubDate>Fri, 11 Sep 2026 00:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/</guid><description>&lt;blockquote>
&lt;p>Fifth article in the operational track of the control layer. &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">The pair with Langfuse&lt;/a> covered observability, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the proxy&amp;rsquo;s day 2&lt;/a> covered availability, &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a> covered governance and &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents&lt;/a> covered the coexistence of two classes of traffic. Here a claim of mine from June gets taken apart. Everything that follows is verified against LiteLLM 1.102.0 (commit of 10 September 2026), vLLM 0.29.0 and the main branches of production-stack, llm-d and Dynamo from the second week of September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>LiteLLM does not do prefix-aware routing, neither in the open version nor in the paid one.&lt;/strong> It is not a licensing question. A search across the whole tree for &lt;code>kv_aware&lt;/code>, &lt;code>cache_aware&lt;/code>, &lt;code>radix&lt;/code>, &lt;code>prefix_cache&lt;/code> and &lt;code>block_hash&lt;/code> returns zero matches in the router. Nor is there any call from the router to &lt;code>/metrics&lt;/code> or to any vLLM endpoint. In June I wrote the opposite in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the inference router article&lt;/a> and I was wrong.&lt;/p>
&lt;p>&lt;strong>What LiteLLM has is affinity, which is a different thing.&lt;/strong> Four pre-call checks pin a request to a replica by session identifier, by key hash, by previous response identifier or by exact hash of a marked prefix. None is active by default. Affinity distributes conversations; prefix routing distributes prefixes shared between different conversations, which is the case that multiplies throughput.&lt;/p>
&lt;p>&lt;strong>The only one that looks at the prompt is fragile in three ways.&lt;/strong> If the client does not send &lt;code>cache_control&lt;/code>, the key comes out null and the check does nothing. If the cut point goes in the last turn, which is what the automatic injection does by default, the key changes on every turn and it never hits. And the pin&amp;rsquo;s TTL is 300 seconds hardcoded in two lines, while the provider offers one-hour caches.&lt;/p>
&lt;p>&lt;strong>The engine does not answer questions, it publishes events.&lt;/strong> There is no vLLM endpoint you can ask whether it has a prefix. What there is, is a ZMQ socket that emits stored and removed blocks in msgpack, switched off by default, with silent discarding when the consumer does not keep up. A router that wants to be exact has to replicate the engine&amp;rsquo;s hash in full: chained SHA256, deterministic seed, block granularity and the extra keys in their order.&lt;/p>
&lt;p>&lt;strong>And by default it does not publish the hits, only the additions.&lt;/strong> The index of a router that only listens drifts away from the engine&amp;rsquo;s real LRU without noticing. To see the hits you have to ask for it request by request with &lt;code>kv_cache_report_mode: full&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The three routers that do do it are very different from each other.&lt;/strong> The vLLM production-stack one indexes chunks of 128 raw characters in a trie that is never purged, models no eviction and dies on every restart. The llm-d one offers an approximate mode and an exact event-driven one, all its plugins are in alpha or beta, and its default tokeniser does not produce real tokens. NVIDIA Dynamo&amp;rsquo;s keeps an event-driven radix tree with a weighted cost function, and ships with the parameter that avoids starving a freshly scaled replica switched off.&lt;/p>
&lt;p>&lt;strong>The big improvements only appear in the best possible case.&lt;/strong> The two- and three-digit numbers are published by the projects themselves, with synthetic shared prefixes and a round-robin balancer as the reference. The most complete independent measurement, from Meta, obtains 2.3 times more throughput and also identifies load regimes where affinity leaves capacity at between half and two thirds of the reference. Another, on a multi-region deployment with 89 % reuse, measures single-digit improvements.&lt;/p>
&lt;p>&lt;strong>The correct architecture has two layers, not one.&lt;/strong> LiteLLM on top for identity, budget and audit, pointing at a single destination per model group. The KV-aware router underneath, inside the engine&amp;rsquo;s domain. Mixing it into a single piece is not an option that exists today.&lt;/p>
&lt;h2 id="you-are-here-between-the-gateway-and-the-engine">You are here: between the gateway and the engine&lt;/h2>
&lt;p>This article occupies the gap between two layers that already have an article of their own. Above, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 switchboard&lt;/a> and the gateway that has been operated since the first article in this track. Below, &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">the fundamentals of the KV cache&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">hit rate engineering&lt;/a>.&lt;/p>
&lt;p>The gap is the piece that decides which replica each request goes to, knowing what each replica has in memory. It is the only function in the stack that cannot be resolved in the gateway or in the engine separately.&lt;/p>
&lt;h2 id="the-analogy-the-library-with-four-counters">The analogy: the library with four counters&lt;/h2>
&lt;p>A library with four counters. Each librarian has in front of them a pile of the books they have consulted lately. When a reader comes in asking for the third volume of an encyclopaedia, if that volume is in the pile at counter two, serving them there takes seconds. At any other counter someone has to go down to the store room.&lt;/p>
&lt;p>A normal balancer sends the reader to the counter with the shortest queue. It is the right decision if all the counters are interchangeable, and it is the wrong decision here, because they are not: what distinguishes them is not the queue, it is the pile.&lt;/p>
&lt;p>Session affinity resolves one specific case of this. The returning reader is always sent to the same counter, because they will probably ask for the fourth volume of the same encyclopaedia. It works, and it does not resolve the general case: when a hundred different readers ask for the same third volume, session affinity spreads them across the four counters and four librarians go down to the store room for the same book.&lt;/p>
&lt;p>Prefix routing is looking at what the reader asks for before assigning them a counter. It has two costs that the analogy makes visible straight away. The first is that somebody has to keep track of what is in each pile, and that bookkeeping takes work. The second is that if everybody asks for the third volume, counter two becomes the library&amp;rsquo;s only desk while the other three look on.&lt;/p>
&lt;h2 id="the-correction-what-i-wrote-in-june">The correction: what I wrote in June&lt;/h2>
&lt;p>In the inference router article, on 2 June 2026, I closed the prefix routing section with this sentence: &amp;ldquo;vLLM Production Stack router implements it natively. NVIDIA Dynamo too. LiteLLM in its enterprise version has a beta.&amp;rdquo;&lt;/p>
&lt;p>The first two hold up. The third is false and was no less false in June. While preparing &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the humans and agents article&lt;/a> the first hint appeared, and full verification against 1.102.0 confirms it.&lt;/p>
&lt;p>Case-insensitive search over &lt;code>litellm/&lt;/code>, &lt;code>enterprise/&lt;/code> and &lt;code>gateway/&lt;/code>, for the terms &lt;code>kv_aware&lt;/code>, &lt;code>kv-aware&lt;/code>, &lt;code>cache_aware&lt;/code>, &lt;code>cache-aware&lt;/code>, &lt;code>radix&lt;/code>, &lt;code>prefix_cache&lt;/code>, &lt;code>prefix-cache&lt;/code>, &lt;code>kv_cache&lt;/code>, &lt;code>block_hash&lt;/code>, &lt;code>prefix_hash&lt;/code>, &lt;code>prefix_score&lt;/code> and &lt;code>longest_prefix&lt;/code>: zero matches. Search for &lt;code>vllm&lt;/code> inside &lt;code>litellm/router.py&lt;/code>, &lt;code>litellm/router_strategy/&lt;/code> and &lt;code>litellm/router_utils/&lt;/code>: zero. Search for &lt;code>num_requests_waiting&lt;/code>, &lt;code>gpu_cache_usage&lt;/code> or any scraping of the backends&amp;rsquo; &lt;code>/metrics&lt;/code> from the router: zero. LiteLLM&amp;rsquo;s router never asks the engine anything.&lt;/p>
&lt;p>Search for &lt;code>premium_user&lt;/code> in the files where those checks would live: a single result, and it is about something else. There is no paid version with this feature. The only routing piece that is behind a licence is per-tag budgets, in &lt;code>litellm/router_strategy/budget_limiter.py&lt;/code>.&lt;/p>
&lt;p>Where the error came from. LiteLLM&amp;rsquo;s documentation has an entry on dynamic TPM and RPM allocation marked as beta and enterprise, and a pre-call check called &lt;code>prompt_caching&lt;/code>. Skim-read and added together, they give the impression of prefix routing in beta. Read in the code, &lt;code>prompt_caching&lt;/code> is something else, and it deserves a section of its own because it is still recommended for this problem.&lt;/p>
&lt;h2 id="what-litellm-does-have-four-affinities">What LiteLLM does have: four affinities&lt;/h2>
&lt;p>The &lt;code>optional_pre_call_checks&lt;/code> field accepts eight values. None is enabled by default: &lt;code>apply_default_settings&lt;/code> passes an empty list. One of the eight, &lt;code>forward_client_headers_by_model_group&lt;/code>, is declared in the type and in the interface schema and is consumed by no branch of the code. It is a dead value.&lt;/p>
&lt;p>The four that pin a request to a replica are these.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Check&lt;/th>
&lt;th>What it pins&lt;/th>
&lt;th>Where it stores it&lt;/th>
&lt;th>TTL&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>session_affinity&lt;/code>&lt;/td>
&lt;td>Session identifier&lt;/td>
&lt;td>Redis with a Lua script, mirrored in memory&lt;/td>
&lt;td>&lt;code>deployment_affinity_ttl_seconds&lt;/code>, 3600 by default, refreshed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>deployment_affinity&lt;/code>&lt;/td>
&lt;td>API key hash&lt;/td>
&lt;td>Same&lt;/td>
&lt;td>Same&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>responses_api_deployment_check&lt;/code>&lt;/td>
&lt;td>&lt;code>previous_response_id&lt;/code>&lt;/td>
&lt;td>Same&lt;/td>
&lt;td>Same&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>prompt_caching&lt;/code>&lt;/td>
&lt;td>Exact hash of a marked prefix&lt;/td>
&lt;td>&lt;code>DualCache&lt;/code>&lt;/td>
&lt;td>&lt;strong>300 s, hardcoded&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The other four are &lt;code>router_budget_limiting&lt;/code>, &lt;code>encrypted_content_affinity&lt;/code>, &lt;code>enforce_model_rate_limits&lt;/code> and the dead value.&lt;/p>
&lt;h3 id="prompt_caching-why-it-is-no-use-for-this">&lt;code>prompt_caching&lt;/code>: why it is no use for this&lt;/h3>
&lt;p>The key is a SHA256 over the serialisation of the cacheable prefix, and &amp;ldquo;cacheable prefix&amp;rdquo; means everything up to and including the last block marked with &lt;code>cache_control: {&amp;quot;type&amp;quot;: &amp;quot;ephemeral&amp;quot;}&lt;/code>. Three consequences, checked by running the functions in isolation over the repository&amp;rsquo;s code.&lt;/p>
&lt;p>If the request carries no &lt;code>cache_control&lt;/code>, the extraction returns an empty list, the key comes out null and the check pins nothing. Automatic injection of cut points is only enabled with &lt;code>litellm.enable_anthropic_prompt_caching&lt;/code>, which defaults to false, and only for Anthropic and Bedrock providers.&lt;/p>
&lt;p>If the cut point is in the system prompt, the key does not change when turns are added and the pin works. This is the good case and it requires the client to mark it deliberately.&lt;/p>
&lt;p>If the cut point is in the last message, the key changes on every turn and the pin never hits. And this is exactly the default case of the automatic injection, which places one point on the &lt;code>system&lt;/code> and another at index &lt;code>-1&lt;/code>. Since the second is the last, the cacheable prefix becomes the whole conversation.&lt;/p>
&lt;p>On top of that there is the TTL. Two lines with &lt;code>ttl=300&lt;/code>, one of them with the comment &lt;code># store for 5 minutes&lt;/code>. There is no parameter to change it. Issue 28427 has had it open for months with no response from the maintainers, and it points to the mismatch with the one-hour ephemeral caches that LiteLLM itself knows how to request via &lt;code>cache_control.ttl: &amp;quot;1h&amp;quot;&lt;/code>.&lt;/p>
&lt;p>There is a nuance the issue does not capture and that makes diagnosis worse: in the default case the pin does not even last five minutes, because the key changes first.&lt;/p>
&lt;h3 id="session_affinity-the-one-to-use">&lt;code>session_affinity&lt;/code>: the one to use&lt;/h3>
&lt;p>For conversations, this is the right piece and it is reasonably built. The session identifier is looked for in this exact order of headers:&lt;/p>
&lt;ol>
&lt;li>&lt;code>x-litellm-trace-id&lt;/code>&lt;/li>
&lt;li>&lt;code>x-litellm-session-id&lt;/code>&lt;/li>
&lt;li>Any &lt;code>x-&amp;lt;vendor&amp;gt;-session-id&lt;/code>, with the regular expression &lt;code>^x-.+-session-id$&lt;/code> and a value of at least eight alphanumeric characters&lt;/li>
&lt;li>Only if the &lt;code>User-Agent&lt;/code> is Codex&amp;rsquo;s: &lt;code>session-id&lt;/code>, &lt;code>session_id&lt;/code>, &lt;code>thread-id&lt;/code>, &lt;code>conversation_id&lt;/code>&lt;/li>
&lt;li>Plain &lt;code>x-session-id&lt;/code>, for opencode&lt;/li>
&lt;/ol>
&lt;p>And if nothing matches, two fallbacks: a &lt;code>metadata.user_id&lt;/code> in Anthropic format, and the W3C &lt;code>baggage&lt;/code> with &lt;code>session.id=&lt;/code>. The third point is the one that captures Claude Code with nothing configured.&lt;/p>
&lt;p>Storage is a Lua script in Redis that does &lt;code>GET&lt;/code>, &lt;code>SET NX EX&lt;/code> and &lt;code>EXPIRE&lt;/code>, so the first writer wins and the TTL is refreshed on every request that confirms the pin. It is an inactivity TTL, not a total session duration. Without Redis it degrades to a pod-local reservation, which is atomic but not shared.&lt;/p>
&lt;p>A detail of the pipeline order that already came up in the previous article and still holds: the affinity checks run inside &lt;code>async_get_healthy_deployments&lt;/code> before tag filtering. If the pin returns a single deployment and that deployment does not satisfy the request&amp;rsquo;s tag, the later filter removes it and the request fails. Affinity gets no vote on the tags.&lt;/p>
&lt;h3 id="the-routers-that-do-read-the-prompt-and-why-they-are-no-good">The routers that do read the prompt, and why they are no good&lt;/h3>
&lt;p>LiteLLM has four pieces that read message content: &lt;code>AutoRouter&lt;/code> with semantic matching, &lt;code>RequestComplexityRouter&lt;/code>, &lt;code>AdaptiveRouter&lt;/code> and &lt;code>QualityRouter&lt;/code>. They all live in &lt;code>async_pre_routing_hook&lt;/code>, which runs before looking for healthy deployments.&lt;/p>
&lt;p>The reason they do not solve this problem is in what they choose. All four choose a &lt;strong>model group&lt;/strong>, not a replica. They decide whether an easy question goes to the thirty-billion-parameter model or to the small one. None has access to the replica list or knows what is in each one&amp;rsquo;s memory.&lt;/p>
&lt;h3 id="what-did-land-admission-control">What did land: admission control&lt;/h3>
&lt;p>A minor correction to the previous article, which placed this in the 1.101 branch as an upcoming feature. In 1.102.0 it is already there, in &lt;code>litellm/proxy/middleware/admission_control_middleware.py&lt;/code>, with the three settings in &lt;code>general_settings&lt;/code>: &lt;code>max_in_flight_requests_per_worker&lt;/code>, &lt;code>max_queued_requests_per_worker&lt;/code> and &lt;code>admission_queue_timeout_seconds&lt;/code>, the last with a default value of 1.0 seconds.&lt;/p>
&lt;p>It stays inactive as long as the first one is not declared. It exposes an authenticated &lt;code>/health/backlog&lt;/code> and the three metrics &lt;code>litellm_admission_admitted_requests&lt;/code>, &lt;code>litellm_admission_queued_requests&lt;/code> and &lt;code>litellm_admission_rejected_requests_total&lt;/code>, the last with a reason label. It is a per-process semaphore, not coordinated across pods.&lt;/p>
&lt;h2 id="what-the-engine-exposes-upwards">What the engine exposes upwards&lt;/h2>
&lt;p>Before looking at the routers that do do this, you have to understand what raw material they work with. The natural question would be whether vLLM has an endpoint you can ask about a prefix. It does not.&lt;/p>
&lt;p>The only cache router in the entry points is &lt;code>vllm/entrypoints/serve/dev/cache/api_router.py&lt;/code>, and it only exposes destructive writes: &lt;code>POST /reset_prefix_cache&lt;/code>, &lt;code>/reset_mm_cache&lt;/code> and &lt;code>/reset_encoder_cache&lt;/code>. All three are behind &lt;code>VLLM_SERVER_DEV_MODE=1&lt;/code> and do not exist in a normal deployment. There is no read.&lt;/p>
&lt;p>What there is, is an event stream.&lt;/p>
&lt;h3 id="the-zmq-stream">The ZMQ stream&lt;/h3>
&lt;p>It is enabled with &lt;code>--kv-events-config&lt;/code>, and it is off by default. The configuration accepts a publisher (&lt;code>null&lt;/code> or &lt;code>zmq&lt;/code>), an endpoint (by default &lt;code>tcp://*:5557&lt;/code>, which binds), an optional replay endpoint, the number of batches retained for that replay (10,000), and a high water mark limit of 100,000.&lt;/p>
&lt;p>That limit is the one that matters operationally: above it, ZMQ discards events if the consumer does not keep up. Silently.&lt;/p>
&lt;p>The transport is a PUB socket with msgpack serialisation and three-part frames: topic, eight-byte sequence number and payload. The sequence number lets a subscriber ask for replay from where it left off through the optional ROUTER socket. With data parallelism the port shifts by rank, so you have to subscribe to &lt;code>5557+rank&lt;/code>.&lt;/p>
&lt;p>Three event types. &lt;code>BlockStored&lt;/code> carries the hashes, the parent hash, the token identifiers, the block size, the LoRA adapter name, the extra keys, the medium (GPU, CPU or storage) and a session identifier. &lt;code>BlockRemoved&lt;/code> carries only hashes. &lt;code>AllBlocksCleared&lt;/code> carries no payload and is emitted when the prefix cache is reset.&lt;/p>
&lt;p>They are published once per scheduler step, only if there are events. With the feature switched off the cost is zero. With it switched on, the full token identifiers of each block travel over the wire, so bandwidth scales with tokens per second and not with requests per second.&lt;/p>
&lt;p>An example in vLLM&amp;rsquo;s own repository declares itself experimental on its eighth line.&lt;/p>
&lt;h3 id="the-hash-that-has-to-be-replicated">The hash that has to be replicated&lt;/h3>
&lt;p>An exact router does not receive the engine&amp;rsquo;s state, it reconstructs it. For that it has to compute the same hashes, and the engine computes them like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># vllm/v1/core/kv_cache_utils.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">hash_block_tokens&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">hash_function&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">parent_block_hash&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">curr_block_token_ids&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">extra_keys&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="n">parent_block_hash&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">parent_block_hash&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">NONE_HASH&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">hash_function&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">parent_block_hash&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">tuple&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">curr_block_token_ids&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">extra_keys&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is chained: each hash identifies the complete prefix up to that boundary, not the isolated block. The default algorithm is SHA256, configurable with &lt;code>--prefix-caching-hash-algo&lt;/code> among four values.&lt;/p>
&lt;p>Five things a router has to get right for its hashes to match the engine&amp;rsquo;s:&lt;/p>
&lt;p>The seed. Since 0.29, &lt;code>NONE_HASH&lt;/code> is deterministic from a fixed seed, so two different processes produce the same hashes with nothing configured. It is a silent change of semantics from earlier versions, which required setting &lt;code>PYTHONHASHSEED&lt;/code>. With the &lt;code>xxhash&lt;/code> algorithms it is still needed.&lt;/p>
&lt;p>The block size. By default 16 tokens, but the attention backend can override it if the user did not pass &lt;code>--block-size&lt;/code>: 256 with sliding-window sparse attention, 64 with ROCm&amp;rsquo;s AITER, 64 or more with FlashAttention on XPU. A router must not assume 16, it has to read it from the event.&lt;/p>
&lt;p>The hashing granularity, which is not necessarily the physical block size. There is &lt;code>--prefix-match-unit&lt;/code>, which with several cache groups resolves to the greatest common divisor of the cacheable groups&amp;rsquo; sizes.&lt;/p>
&lt;p>The extra keys, in their order: LoRA adapter name (by name, not by identifier), multimodal identifiers with their relative offset inside the block, &lt;code>cache_salt&lt;/code> only in block zero, and the hash of the prompt embeddings. Sampling parameters do not go in, which is correct: the prefix KV does not depend on the temperature.&lt;/p>
&lt;p>And truncation. By default the hashes travel over the wire as 64-bit integers truncated from a SHA256, not as the full bytes. It can be disabled with &lt;code>VLLM_KV_EVENTS_USE_INT_BLOCK_HASHES=0&lt;/code>. With the default value, a router has a theoretical collision risk that the engine does not have.&lt;/p>
&lt;h3 id="the-hits-are-invisible-by-default">The hits are invisible by default&lt;/h3>
&lt;p>This is the detail that takes the most effort to discover and has the most consequences. The events are incremental: only what gets newly cached is announced. A reused block generates no event.&lt;/p>
&lt;p>A router that only listens sees the additions and the removals, but does not see the hits. Its notion of what is recent and what is cold drifts away from the engine&amp;rsquo;s real LRU with nothing to warn it.&lt;/p>
&lt;p>A full mode exists, and it is per request. &lt;code>sampling_params.extra_args[&amp;quot;kv_cache_report_mode&amp;quot;] = &amp;quot;full&amp;quot;&lt;/code> makes &lt;code>BlockStored&lt;/code> events be emitted for blocks that were hits too. The function&amp;rsquo;s docstring says so plainly: it generates events so that external consumers, &amp;ldquo;for example a gateway&amp;rdquo;, know which blocks were reused. That is to say: for this to work, the router has to inject a parameter into every request it forwards.&lt;/p>
&lt;h3 id="the-metrics-and-what-broke">The metrics, and what broke&lt;/h3>
&lt;p>For aggregate measurement two counters remain: &lt;code>vllm:prefix_cache_queries_total&lt;/code> and &lt;code>vllm:prefix_cache_hits_total&lt;/code>, both in tokens and not in requests, with model and engine labels. There are two equivalents for the external connector, which allow a hit in HBM to be distinguished from one brought in from CPU or NVMe.&lt;/p>
&lt;p>And there are two removals that break existing configurations. &lt;code>vllm:gpu_prefix_cache_hit_rate&lt;/code> no longer exists: zero appearances in the repository. It was deprecated in 0.8, hidden in 0.9 and removed in 0.10. And &lt;code>vllm:gpu_cache_usage_perc&lt;/code> was renamed to &lt;code>vllm:kv_cache_usage_perc&lt;/code>, which breaks Grafana panels and autoscalers that depended on the old name.&lt;/p>
&lt;p>The hit rate has to be derived in PromQL with &lt;code>rate()&lt;/code> over the two counters. The one that appears in the text log is something else: it uses a window of the last thousand requests.&lt;/p>
&lt;p>And it has a bias you need to know about before taking decisions with it. When a request is evicted, its blocks return to the free queue keeping the hash, so the later reprefill finds them again and &lt;strong>counts as a hit&lt;/strong>. A high hit rate can mean useful reuse between users or it can mean the engine is evicting and recomputing in a loop. The internal statistics separate the two things with a &lt;code>preempted&lt;/code> field, which is not exposed as a Prometheus metric.&lt;/p>
&lt;h3 id="the-last-token">The last token&lt;/h3>
&lt;p>A small detail with an effect on long conversations: the maximum hit length is the request&amp;rsquo;s token count minus one. There is always something to recompute in order to get logits. Since the reservation requires alignment to the block size, in practice that can force recomputing the whole final block. With blocks of 16 it is noise. With a backend that prefers 256, it is up to 256 tokens recomputed per turn.&lt;/p>
&lt;h2 id="the-ones-that-do-do-it">The ones that do do it&lt;/h2>
&lt;h3 id="vllm-production-stack">vLLM production-stack&lt;/h3>
&lt;p>Eight routing logics in a single &lt;code>--routing-logic&lt;/code> selector: &lt;code>roundrobin&lt;/code>, &lt;code>session&lt;/code>, &lt;code>kvaware&lt;/code>, &lt;code>loadaware&lt;/code>, &lt;code>prefixaware&lt;/code>, &lt;code>disaggregated_prefill&lt;/code>, &lt;code>disaggregated_prefill_orchestrated&lt;/code> and &lt;code>priority&lt;/code>. Two of them, &lt;code>loadaware&lt;/code> and &lt;code>priority&lt;/code>, are not in the Helm chart&amp;rsquo;s enum and are only enabled through &lt;code>extraArgs&lt;/code>.&lt;/p>
&lt;p>The naming trap is that &lt;code>prefixaware&lt;/code> and &lt;code>kvaware&lt;/code> are radically different things.&lt;/p>
&lt;p>&lt;code>prefixaware&lt;/code> is a hash trie in the router&amp;rsquo;s process. It chunks the prompt string every 128 &lt;strong>characters&lt;/strong>, not tokens, and hashes each chunk with 64-bit xxhash. Its own docstring says so: the chunk size is &amp;ldquo;in number of characters&amp;rdquo;. That 128 is hardcoded, there is no flag or Helm value to change it.&lt;/p>
&lt;p>Four consequences come out of that. &lt;code>--prefix-min-match-length&lt;/code> is measured in characters and is quantised to multiples of 128. A shared prefix that differs by one character inside the first chunk breaks the whole match, because there is no partial match inside a chunk. For chat, it concatenates the content of all the messages with newlines and without applying the chat template, so the indexed string is not what the engine tokenises. And the tie-break between tied replicas is &lt;code>random.choice&lt;/code>, without looking at load.&lt;/p>
&lt;p>The trie has no notion of saturation. A popular prefix concentrates all the traffic on the same replica indefinitely. The docstring assumes the simplified model and declares it: &amp;ldquo;we assume there is no prefix cache eviction&amp;rdquo;.&lt;/p>
&lt;p>It has no pruning either. &lt;code>HashTrie&lt;/code> only has insertion and longest prefix lookup; there is no deletion method. When a replica disappears, its URLs stay forever in every node&amp;rsquo;s sets, and filtering happens at query time. It works, and the trie grows monotonically: each distinct prompt adds its length divided by 128 permanent nodes, with no bound, no TTL and no LRU. The router&amp;rsquo;s memory limit in the chart is 1,000 MiB. By contrast, the round-robin router in the same file does bound its caches to 1,024 entries.&lt;/p>
&lt;p>And it dies on every restart: it is in-process memory state, with no snapshot and no initial load.&lt;/p>
&lt;p>&lt;code>kvaware&lt;/code> and &lt;code>loadaware&lt;/code> are better and have a different price. They do not index themselves: they ask the LMCache controller, which knows about real tokens. That requires &lt;code>lmcache==0.3.11&lt;/code> and &lt;code>vllm==0.13.0&lt;/code> pinned exactly, and the official router image drags in the whole of vLLM. The limitation to read before planning anything: they only look at &lt;code>request_json.get(&amp;quot;prompt&amp;quot;, &amp;quot;&amp;quot;)&lt;/code>, with an open TODO for chat completions. With &lt;code>/v1/chat/completions&lt;/code>, both tokenise an empty string.&lt;/p>
&lt;p>&lt;code>loadaware&lt;/code> is the only one of the three that breaks affinity on saturation, with the formula &lt;code>score = relative_match - beta * relative_load&lt;/code> and &lt;code>beta = 1.0&lt;/code> by default. The authors&amp;rsquo; own reading: a replica at twice the average load loses the equivalent of a full cache hit. The guide suggests 0.25 to favour cache and 2.0 to favour balancing.&lt;/p>
&lt;p>On published results, the documentation&amp;rsquo;s benchmarking file literally says that the measurement platform is coming soon. The project blog&amp;rsquo;s two posts claim between three and ten times lower response latency, and only one declares a partial configuration: Llama 3.1 70B on four nodes with tensor parallelism 2 over 80 GB A100s, inputs of 9K tokens and outputs of 10, against AIBrix 0.2.0 and a flat deployment. Charts with no labelled points, no absolute figures and no declaration of which routing logic was used in each.&lt;/p>
&lt;h3 id="llm-d">llm-d&lt;/h3>
&lt;p>There has been a reorganisation here that invalidates a good part of the third-party documentation. The Endpoint Picker code left the Gateway API Inference Extension repository and was merged into llm-d&amp;rsquo;s, which has been renamed to llm-d Router. The Kubernetes project keeps the &lt;code>InferencePool&lt;/code> API and a lightweight reference EPP that does nothing about prefixes. If you look for KV-aware routing in the extension&amp;rsquo;s repository, today it is not there.&lt;/p>
&lt;p>The &lt;code>InferencePool&lt;/code> is at &lt;code>v1&lt;/code> and is GA. The other two APIs moved to the &lt;code>llm-d.ai&lt;/code> group at &lt;code>v1alpha2&lt;/code>, and when they coexist with the old ones the EPP prefers the new ones and ignores the old.&lt;/p>
&lt;p>Scoring is a weighted sum. The default weights, when no configuration is passed: queue 2, KV utilisation 2, prefix 3. Any scorer referenced without a weight counts as 1.0. There is no configuration through environment variables: the old &lt;code>ENABLE_*_SCORER&lt;/code> no longer exist, everything goes in an &lt;code>EndpointPickerConfig&lt;/code> in YAML.&lt;/p>
&lt;p>The prefix scorer consumes an attribute published by a producer, and there are two producers.&lt;/p>
&lt;p>The &lt;strong>approximate&lt;/strong> one computes the hashes in the EPP itself without talking to the engine, and keeps a local LRU per pod and block pair that is populated after the decision. It is an approximation by construction. It has a hard floor: a configured block size below 64 tokens is raised to 64 at request time, because the index stores one entry per pod and block and going down to 16 would multiply memory by four.&lt;/p>
&lt;p>The &lt;strong>exact&lt;/strong> one builds the real index from the engine&amp;rsquo;s ZMQ events, with one subscriber per pod that is installed and torn down with the endpoint&amp;rsquo;s registration and deregistration. It has adapters for vLLM and SGLang, deduplicates evictions by reference counting, and the replay buffer that allows the index to be rebuilt after an EPP restart requires vLLM 0.26.0 or above.&lt;/p>
&lt;p>The trap is in the tokeniser. The EPP tokenises through a plugin, and the default backend is &lt;code>estimate&lt;/code>: byte packing with no tokeniser, roughly 4 bytes per token, with identifiers that do not correspond to real engine tokens. The exact scorer needs real tokens, and for that you have to explicitly configure the &lt;code>vllm&lt;/code> backend, which does HTTP against the engine&amp;rsquo;s &lt;code>/v1/completions/render&lt;/code> endpoints. If it is omitted, the auto-created &lt;code>estimate&lt;/code> satisfies the dependency and the system &lt;strong>degrades silently&lt;/strong>.&lt;/p>
&lt;p>On maturity, the file itself declares it: all in-tree plugins are in alpha or beta, and they will be promoted to stable when the project approaches 1.0. An alpha plugin configured without the corresponding flag makes the EPP fail to start.&lt;/p>
&lt;p>And on replicas, the warning is explicit: active-active mode should be avoided with approximate prefix routing, because the EPP replicas do not share state and each one only sees the traffic it has served itself, which significantly degrades the hit rate. The exact index is safe in high availability, and even so the reference guides fix a single replica because the accounting of in-flight requests is local to the process.&lt;/p>
&lt;p>The cost is the best documented in the whole field, and it deserves a table. Simulator with Qwen3 8B, 100 pods, 100K input tokens:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Requests/s&lt;/th>
&lt;th>Cap on tokens to match&lt;/th>
&lt;th>Peak CPU&lt;/th>
&lt;th>Peak memory&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>5.0&lt;/td>
&lt;td>4,096&lt;/td>
&lt;td>1.19 cores&lt;/td>
&lt;td>0.26 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5.0&lt;/td>
&lt;td>100,000&lt;/td>
&lt;td>3.82 cores&lt;/td>
&lt;td>0.65 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>98.7&lt;/td>
&lt;td>4,096&lt;/td>
&lt;td>35.17 cores&lt;/td>
&lt;td>2.46 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>98.8&lt;/td>
&lt;td>100,000&lt;/td>
&lt;td>46.50 cores&lt;/td>
&lt;td>3.41 GiB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With outputs of 10K tokens at 50 requests per second, 32.53 cores and 12.54 GiB. The maintainers&amp;rsquo; sizing rule is half a core to one core per request per second on large agentic loads. And idle consumption scales with the number of pods because of continuous scraping: with 100 pods, about 7.5 cores with no traffic.&lt;/p>
&lt;h3 id="nvidia-dynamo">NVIDIA Dynamo&lt;/h3>
&lt;p>A precise event-driven index, with a global radix tree in each frontend&amp;rsquo;s process. Since all the replicas consume the same event plane, there is no need to synchronise routers with each other for the prefix state.&lt;/p>
&lt;p>The cost function is explicit and weighted. It discounts from the prefill the blocks that overlap in GPU, host and disk with different weights (1.0 for the overlap credit, 0.75 for host, 0.25 for disk), and adds the potential decode blocks and the active requests.&lt;/p>
&lt;p>There is a parameter to look at before deploying: &lt;code>--router-kv-overlap-score-credit-decay&lt;/code> is at &lt;strong>0&lt;/strong> by default, that is, disabled. Its description says what it is for: preventing loaded, cache-rich replicas from winning again and again while the freshly autoscaled ones receive too little traffic. With the default value, a new pod has no cache, therefore never wins the scoring, therefore never generates cache.&lt;/p>
&lt;p>The approximate mode exists (&lt;code>--no-router-kv-events&lt;/code>, with a TTL of 120 seconds) and the documentation says it is not the recommended route in production.&lt;/p>
&lt;p>Dynamo is the most honest project in its measurement methodology: its comparison guide recommends contrasting &lt;code>--router-mode random&lt;/code> against &lt;code>kv&lt;/code> over the public Mooncake trace from FAST'25, with a declared cache ratio of 59 %, and it generates the table locally instead of publishing a number.&lt;/p>
&lt;h3 id="the-rest-of-the-field">The rest of the field&lt;/h3>
&lt;p>&lt;strong>SGLang&lt;/strong> has renamed its router to &lt;code>sgl-model-gateway&lt;/code>, version 0.3.2 within SGLang 0.5.19. Its &lt;code>cache_aware&lt;/code> policy is an approximate radix tree over raw characters, with switching to shortest queue when the system is unbalanced according to two combined thresholds. One detail that can cost an afternoon: the Rust defaults and the Python CLI defaults &lt;strong>do not match&lt;/strong>, and on the maximum tree size they differ by four orders of magnitude (10,000 nodes against 2^26).&lt;/p>
&lt;p>&lt;strong>AIBrix&lt;/strong> 0.7.0 offers a standard mode with a local hash table and a KV event synchronisation mode behind a flag and a build tag, with vLLM 0.7.0 or above.&lt;/p>
&lt;p>&lt;strong>Ray Serve LLM&lt;/strong> has &lt;code>PrefixCacheAffinityRouter&lt;/code> with an explicit alpha API warning, a character-based prefix tree in a decoupled actor, and its imbalance threshold at infinity by default, that is, out of the box it never breaks affinity on load.&lt;/p>
&lt;p>&lt;strong>Envoy AI Gateway&lt;/strong> 1.1.0 implements none of this and delegates to &lt;code>InferencePool&lt;/code> and the EPP. &lt;strong>KServe&lt;/strong> the same, with its &lt;code>LLMInferenceService&lt;/code> at &lt;code>v1alpha1&lt;/code>. &lt;strong>Mooncake Conductor&lt;/strong> is still an unimplemented proposal.&lt;/p>
&lt;h3 id="comparison-table">Comparison table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Project&lt;/th>
&lt;th>Version and date&lt;/th>
&lt;th>Index&lt;/th>
&lt;th>Where it lives&lt;/th>
&lt;th>Kubernetes&lt;/th>
&lt;th>Declared maturity&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>production-stack &lt;code>prefixaware&lt;/code>&lt;/td>
&lt;td>main branch, 09-Sep-2026&lt;/td>
&lt;td>Approximate, character trie, no eviction&lt;/td>
&lt;td>Router process&lt;/td>
&lt;td>Optional&lt;/td>
&lt;td>WIP in the README&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>production-stack &lt;code>kvaware&lt;/code>/&lt;code>loadaware&lt;/code>&lt;/td>
&lt;td>same&lt;/td>
&lt;td>Exact via LMCache controller&lt;/td>
&lt;td>LMCache controller&lt;/td>
&lt;td>Optional&lt;/td>
&lt;td>No chat completions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>llm-d Router approximate&lt;/td>
&lt;td>v0.10.0, 17-Aug-2026&lt;/td>
&lt;td>Approximate, LRU per pod&lt;/td>
&lt;td>EPP process&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Beta, active-active discouraged&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>llm-d Router exact&lt;/td>
&lt;td>same&lt;/td>
&lt;td>Exact through ZMQ events&lt;/td>
&lt;td>EPP process&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Beta&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVIDIA Dynamo&lt;/td>
&lt;td>v1.4.2, 27-Aug-2026&lt;/td>
&lt;td>Exact, event-driven radix tree&lt;/td>
&lt;td>Frontend process&lt;/td>
&lt;td>No&lt;/td>
&lt;td>GA with experimental subsystems&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SGLang &lt;code>sgl-model-gateway&lt;/code>&lt;/td>
&lt;td>0.3.2, 03-Sep-2026&lt;/td>
&lt;td>Approximate, character radix&lt;/td>
&lt;td>Gateway process&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Default policy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AIBrix&lt;/td>
&lt;td>v0.7.0, 16-Jun-2026&lt;/td>
&lt;td>Both&lt;/td>
&lt;td>Gateway plugin&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Synchronisation behind a flag&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ray Serve LLM&lt;/td>
&lt;td>Ray 2.58&lt;/td>
&lt;td>Approximate, characters&lt;/td>
&lt;td>Ray actor&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Declared alpha&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Envoy AI Gateway&lt;/td>
&lt;td>v1.1.0, 21-Aug-2026&lt;/td>
&lt;td>None of its own&lt;/td>
&lt;td>Delegates to EPP&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Stable 1.x API&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-much-it-really-improves">How much it really improves&lt;/h2>
&lt;p>Here two types of source have to be separated.&lt;/p>
&lt;p>&lt;strong>The projects&amp;rsquo; own numbers.&lt;/strong> Red Hat published the llm-d case in May: Qwen3 32B, eight vLLM pods over 16 H100s with tensor parallelism 2, synthetic shared-prefix load with 150 groups of five prompts, 6,000 tokens of system prompt, 1,200 of question and 1,000 of output. Result: up to 109 % more throughput and up to 99 % less TTFT, around 200 concurrent users within SLO against around 20. The repository&amp;rsquo;s result files give more detail: peak throughput from 6,986 to 14,892 output tokens per second, p90 TTFT from 135.5 seconds to 0.26.&lt;/p>
&lt;p>That same file publishes the regression, and that is why it is worth reading: p50 inter-token latency rises by 22.4 % with vLLM and by 45.4 % with SGLang. The trade-off is declared: routing by affinity concentrates more concurrent work on the pods with a warm cache.&lt;/p>
&lt;p>It is the best possible case. A synthetic shared prefix and a reference that is a flat Kubernetes &lt;code>Service&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The independent measurements.&lt;/strong> There are three and they say different things.&lt;/p>
&lt;p>&lt;em>CacheRoute&lt;/em>, from Meta, August 2026, is the most complete. Llama 3.3 70B in fp8, 30 targets with tensor parallelism 2 over 60 H100s, a semi-synthetic trace from production telemetry with 128,824 business keys, and all the alternatives reimplemented in the same harness instead of comparing against a dumb balancer. It obtains 176 requests per second with a p99 SLO of 3.5 seconds against 76 for the best competitor, and a hit rate of 93.2 % against 72.0 %.&lt;/p>
&lt;p>And it publishes two negative findings that nobody else publishes. There are load regimes where affinity &lt;strong>reduces&lt;/strong> capacity to between half and two thirds of the reference, to the point that the authors make a mirrored-traffic trial mandatory before deploying. And growing the fleet can cool a prefix even though total cache capacity grows, because the time between revisits stretches beyond the eviction window. Scaling horizontally can make the hit rate worse.&lt;/p>
&lt;p>&lt;em>GORGO&lt;/em>, June 2026, measures on a multi-region deployment with a dataset where intra-user prefix reuse is 89.4 % and the average prompt is almost 18,000 tokens. An improvement of 6.9 to 15.5 % in p95 TTFT. With extremely high reuse, the improvement is single-digit, because network latency and queuing dominate.&lt;/p>
&lt;p>&lt;em>LAAR&lt;/em>, from IBM Research Tokyo, April 2026, is the uncomfortable counterpoint: in its comparison, session affinity routing was the &lt;strong>worst&lt;/strong> of the baselines, and at 64K contexts a load-based routing gives lower absolute latency at the cost of fewer hits.&lt;/p>
&lt;p>The joint reading fits in three conditions. The big improvements require high, concentrated prefix reuse, long prompts, and a weak reference. Take away any of the three and the numbers deflate.&lt;/p>
&lt;h2 id="the-five-failure-modes">The five failure modes&lt;/h2>
&lt;p>&lt;strong>Hot spot from affinity.&lt;/strong> The busiest bucket inherits the load&amp;rsquo;s skew, and tail latency starts following the hottest target instead of the fleet average. Mitigations exist in the four main products and in three of the four they ship &lt;strong>switched off&lt;/strong>: Dynamo&amp;rsquo;s credit decay at 0, Ray&amp;rsquo;s imbalance threshold at infinity, and production-stack&amp;rsquo;s &lt;code>prefixaware&lt;/code> with none at all.&lt;/p>
&lt;p>&lt;strong>Starvation of the freshly scaled pod.&lt;/strong> A new pod has no cache, so it never wins the scoring, so it never warms up. Dynamo documents it as the reason for the decay parameter. I found no primary documentation from any of the projects about the specific interaction with KEDA.&lt;/p>
&lt;p>&lt;strong>Cost of the index.&lt;/strong> The llm-d numbers in the previous table are the best public reference that exists. Raising the cap on tokens to match from 16,384 to 400,000 can more than double the EPP&amp;rsquo;s CPU with little traffic.&lt;/p>
&lt;p>&lt;strong>Traffic with unique prefixes.&lt;/strong> Each project degrades to something different. SGLang routes to the smallest tree, which is not the same as the least loaded. Ray falls back to power of two choices. Dynamo has an explicit preset, &lt;code>--load-aware&lt;/code>, to say &amp;ldquo;I want the load model without reuse&amp;rdquo;. And production-stack&amp;rsquo;s &lt;code>prefixaware&lt;/code> keeps inserting into the trie, that is, keeps paying the cost while receiving nothing.&lt;/p>
&lt;p>&lt;strong>Sibling bursts.&lt;/strong> Parallel sampling, best of N, or an agent that opens five branches at once. With event-driven routing, no engine has yet emitted the block stored event, all the siblings score zero overlap, and the prefix is precomputed on every replica. Dynamo has a prediction TTL for this; the others do not.&lt;/p>
&lt;h2 id="the-architecture-that-comes-out-of-all-of-the-above">The architecture that comes out of all of the above&lt;/h2>
&lt;p>Two layers, with the responsibilities separated.&lt;/p>
&lt;p>On top, LiteLLM, with what it does do well: non-forgeable identity through the virtual key, budgets, spend logging, concurrency limits, fallback between providers and API unification. For the gateway, each model group has &lt;strong>a single destination&lt;/strong>: the URL of the router below.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://kv-router.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/VLLM_KEY&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">optional_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;session_affinity&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deployment_affinity_ttl_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Below, the KV-aware router, inside the engine&amp;rsquo;s domain, talking to the vLLM replicas and consuming their events.&lt;/p>
&lt;p>Four consequences of this separation that have to be accepted before building it.&lt;/p>
&lt;p>Per-replica visibility in LiteLLM is lost. For the gateway there is a single deployment, so its cooldowns, its per-deployment counters and its strategies stop meaning anything. Observability of which replica served what goes downstairs.&lt;/p>
&lt;p>Retries stack up. The client SDK retries, LiteLLM retries, and the router below may have its own failover. In &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the previous article&lt;/a> 45 calls per turn came out with three layers; with four it is worse. Lowering &lt;code>num_retries&lt;/code> in the gateway to 1, or to 0, is the sensible decision when there is a router below that already retries.&lt;/p>
&lt;p>LiteLLM&amp;rsquo;s session affinity stops making sense for the KV, because it no longer chooses a replica. It is kept if it is used for something else, such as pinning a conversation to a specific provider.&lt;/p>
&lt;p>And the choice of the router below conditions the rest of the cluster far more than the gateway&amp;rsquo;s does. llm-d requires Kubernetes 1.32 or above, Gateway API, the inference extension and a conformant Gateway. Dynamo does not require Kubernetes. production-stack has a Helm chart and works outside it. That decision is the expensive one, not the gateway&amp;rsquo;s.&lt;/p>
&lt;h2 id="when-not-to-build-this">When not to build this&lt;/h2>
&lt;p>Three cases where the correct answer is not to do it, at least not yet.&lt;/p>
&lt;p>&lt;strong>A single replica per model.&lt;/strong> With one replica, the engine&amp;rsquo;s prefix cache already does all the work and there is nothing to route. That is the case for a good part of the &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">four H100&lt;/a> deployments with one large model and tensor parallelism.&lt;/p>
&lt;p>&lt;strong>Low or scattered prefix reuse.&lt;/strong> Before building anything, the measurement is &lt;code>rate(vllm:prefix_cache_hits_total[5m]) / rate(vllm:prefix_cache_queries_total[5m])&lt;/code> per replica, with a mental correction for the bias from reprefills after eviction. If it is already high with blind distribution, reuse is concentrated inside each conversation, and there LiteLLM&amp;rsquo;s session affinity costs one line of configuration and resolves the case.&lt;/p>
&lt;p>&lt;strong>Traffic where the network dominates.&lt;/strong> The GORGO result is the warning: with enormous prompts and a distributed deployment, the improvement is eaten up by queuing and network latency.&lt;/p>
&lt;p>The case where it does pay off is specific: several replicas of the same model, a large system prompt shared between different users, or a RAG with a corpus of documents that repeat across requests. There the prefill saved is real and it is most of the work.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Measure the current hit rate per replica with the two vLLM counters before touching anything, and check whether the engine is evicting with &lt;code>vllm:num_preemptions_total&lt;/code>.&lt;/li>
&lt;li>Check that prefix caching is switched on. It is no longer &lt;code>--enable-prefix-caching&lt;/code> that switches it on: the default value is derived from the model and the log of the decision is at DEBUG level. What is actionable today is &lt;code>--no-enable-prefix-caching&lt;/code> to switch it off.&lt;/li>
&lt;li>Audit the prompts before routing: a timestamp or a session identifier at the start of the system prompt breaks the match from block zero and no routing layer fixes it.&lt;/li>
&lt;li>If the KV event stream is enabled, size the bandwidth by tokens per second and watch discarding at the high water mark. With data parallelism, subscribe to one port per rank.&lt;/li>
&lt;li>If an exact router is chosen, verify the vLLM version against the one the replay buffer requires.&lt;/li>
&lt;li>If llm-d is chosen, explicitly configure the &lt;code>vllm&lt;/code> tokenisation backend. The default &lt;code>estimate&lt;/code> does not fail, it degrades.&lt;/li>
&lt;li>If production-stack is chosen with &lt;code>kvaware&lt;/code> or &lt;code>loadaware&lt;/code>, check first whether the traffic goes through &lt;code>/v1/chat/completions&lt;/code>, because today it does not work there.&lt;/li>
&lt;li>Review the load-based affinity-breaking parameter, because in three of the four main projects it ships disabled.&lt;/li>
&lt;li>Lower &lt;code>num_retries&lt;/code> in LiteLLM when there is a router below that already does failover.&lt;/li>
&lt;li>Trial with mirrored traffic before changing production. It is the explicit recommendation of the only independent measurement that went looking for bad regimes, and found them.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>LiteLLM has no prefix routing in any version.&lt;/strong> Neither the open one nor the paid one. I got it wrong in June.&lt;/li>
&lt;li>&lt;strong>&lt;code>prompt_caching&lt;/code> does nothing if the client does not send &lt;code>cache_control&lt;/code>&lt;/strong>, and with the default automatic injection it fails on every turn because it marks the last message.&lt;/li>
&lt;li>&lt;strong>That pin&amp;rsquo;s TTL is 300 hardcoded seconds&lt;/strong>, incompatible with the one-hour caches that LiteLLM itself knows how to request.&lt;/li>
&lt;li>&lt;strong>production-stack&amp;rsquo;s &lt;code>prefixaware&lt;/code> indexes characters, not tokens&lt;/strong>, in non-configurable chunks of 128, and its trie is never purged and models no eviction.&lt;/li>
&lt;li>&lt;strong>production-stack&amp;rsquo;s &lt;code>kvaware&lt;/code> and &lt;code>loadaware&lt;/code> do not support chat completions.&lt;/strong> They tokenise an empty string.&lt;/li>
&lt;li>&lt;strong>The default tokeniser in llm-d&amp;rsquo;s EPP is a byte estimate&lt;/strong>, and with it the exact scorer degrades silently.&lt;/li>
&lt;li>&lt;strong>llm-d&amp;rsquo;s active-active mode is discouraged with approximate prefixes&lt;/strong>, and the reference guides fix one replica even with the exact one.&lt;/li>
&lt;li>&lt;strong>Dynamo&amp;rsquo;s overlap credit decay ships at zero&lt;/strong>, which can leave a freshly autoscaled replica with no traffic.&lt;/li>
&lt;li>&lt;strong>The SGLang router&amp;rsquo;s default values differ between the Rust and the Python CLI&lt;/strong>, by as much as four orders of magnitude.&lt;/li>
&lt;li>&lt;strong>&lt;code>vllm:gpu_prefix_cache_hit_rate&lt;/code> no longer exists&lt;/strong> and &lt;code>vllm:gpu_cache_usage_perc&lt;/code> is now called &lt;code>vllm:kv_cache_usage_perc&lt;/code>. Old panels and autoscalers are measuring nothing.&lt;/li>
&lt;li>&lt;strong>The hit rate counts the reprefill after eviction as a hit.&lt;/strong> A high number can be reuse or it can be thrashing.&lt;/li>
&lt;li>&lt;strong>KV events do not publish the hits by default&lt;/strong>, only the additions, unless full mode is requested request by request.&lt;/li>
&lt;li>&lt;strong>The hashes travel truncated to 64 bits&lt;/strong> unless it is disabled with an environment variable.&lt;/li>
&lt;li>&lt;strong>The default block size is 16, and the attention backend can change it&lt;/strong> without anyone asking.&lt;/li>
&lt;li>&lt;strong>The Endpoint Picker left the Gateway API Inference Extension repository.&lt;/strong> Looking for it there today leads nowhere.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>June&amp;rsquo;s error has a reading that goes beyond the erratum. It came from reading a product&amp;rsquo;s documentation and not its code, and from the fact that LiteLLM&amp;rsquo;s documentation uses the word &amp;ldquo;caching&amp;rdquo; in a pre-call check that does something else. The distance between what a name suggests and what a function does is, at this point in the track, the most repeated pattern of the whole series.&lt;/p>
&lt;p>The architectural conclusion is that KV-aware routing is not a gateway function. It is not one today in LiteLLM and probably should not be: it requires keeping an index synchronised with each engine&amp;rsquo;s memory, replicating its hash function, consuming an event stream and sizing CPU per request per second. That belongs to the engine&amp;rsquo;s domain, not to that of identity and budget.&lt;/p>
&lt;p>What remains is a two-part decision. The first is whether the traffic deserves it, and that is answered with two Prometheus counters and without installing anything. The second is which of the three routers fits the cluster you already have, and there the deciding variable is not the hit rate but whether you are willing to bring in Gateway API and the inference extension, or to pin vLLM 0.13.0, or to operate a Dynamo frontend.&lt;/p>
&lt;p>And the honesty the material demands: the only independent measurement that specifically went looking for regimes where this makes things worse found them, and they are not marginal. Half to two thirds of reference capacity is a serious regression. That no project publishes that side of the curve does not mean it does not exist in theirs.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a>, the session affinity and the pipeline order taken as read here, and the rest of what the gateway can do with two classes of traffic.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a>, the June article this one corrects, and the router&amp;rsquo;s other four functions that remain valid.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache: hit rate engineering&lt;/a>, the template audit to do before even considering routing anything.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: fundamentals&lt;/a>, what exactly it is that you are trying not to recompute.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/long-context-kv-offloading-notebook-desk/">Long context and KV offloading&lt;/a>, LMCache and the memory hierarchy that &lt;code>kvaware&lt;/code> and Dynamo&amp;rsquo;s per-tier weights depend on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Prefill optimisations in vLLM&lt;/a>, the specific work saved when routing gets it right.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The vLLM scheduler step&lt;/a>, the loop where the KV events are published, once per step.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">OTel instrumentation in vLLM&lt;/a>, the engine&amp;rsquo;s metrics, including the ones that have changed name.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the switchboard&lt;/a>, why the gateway is chosen on licence and fit before features, which is exactly what this article illustrates again.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>, the replica deployment that everything above operates on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a>, why without this article&amp;rsquo;s session affinity the capacity calculation describes a different system, with the prefix hit rate measured in production.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Router - Load Balancing&lt;/em> (estrategias, comprobaciones previas, afinidad): &lt;a href="https://docs.litellm.ai/docs/routing">https://docs.litellm.ai/docs/routing&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código: &lt;code>litellm/router.py&lt;/code>, &lt;code>litellm/router_utils/prompt_caching_cache.py&lt;/code>, &lt;code>litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py&lt;/code>, &lt;code>litellm/router_utils/pre_call_checks/deployment_affinity_check.py&lt;/code>, &lt;code>litellm/proxy/litellm_pre_call_utils.py&lt;/code>, &lt;code>litellm/proxy/middleware/admission_control_middleware.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 28427, TTL de afinidad por caché de prompts fijado a cinco minutos: &lt;a href="https://github.com/BerriAI/litellm/issues/28427">https://github.com/BerriAI/litellm/issues/28427&lt;/a>.&lt;/li>
&lt;li>vLLM, código: &lt;code>vllm/v1/core/kv_cache_utils.py&lt;/code>, &lt;code>vllm/v1/core/block_pool.py&lt;/code>, &lt;code>vllm/v1/core/kv_cache_manager.py&lt;/code>, &lt;code>vllm/distributed/kv_events.py&lt;/code>, &lt;code>vllm/config/kv_events.py&lt;/code>, &lt;code>vllm/v1/metrics/loggers.py&lt;/code>, &lt;code>vllm/v1/metrics/stats.py&lt;/code>: &lt;a href="https://github.com/vllm-project/vllm">https://github.com/vllm-project/vllm&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>KV offloading usage&lt;/em> (niveles CPU, filesystem y objeto; compartición entre procesos): &lt;a href="https://docs.vllm.ai/en/latest/features/kv_offloading_usage.html">https://docs.vllm.ai/en/latest/features/kv_offloading_usage.html&lt;/a>.&lt;/li>
&lt;li>vLLM, notas de la versión 0.29.0 (semilla determinista, seguimiento de propiedad en eventos, flags de admisión): &lt;a href="https://github.com/vllm-project/vllm/releases/tag/v0.29.0">https://github.com/vllm-project/vllm/releases/tag/v0.29.0&lt;/a>.&lt;/li>
&lt;li>vLLM, RFC 16669, publicación de bloques KV y métricas: &lt;a href="https://github.com/vllm-project/vllm/issues/16669">https://github.com/vllm-project/vllm/issues/16669&lt;/a>.&lt;/li>
&lt;li>vLLM production-stack, código: &lt;code>src/vllm_router/routers/routing_logic.py&lt;/code>, &lt;code>src/vllm_router/prefix/hashtrie.py&lt;/code>, &lt;code>src/vllm_router/parsers/parser.py&lt;/code>: &lt;a href="https://github.com/vllm-project/production-stack">https://github.com/vllm-project/production-stack&lt;/a>.&lt;/li>
&lt;li>LMCache Lab, resultados de production-stack frente a AIBrix (parte interesada): &lt;a href="https://blog.lmcache.ai/en/2025/03/06/open-source-llm-inference-cluster-performing-10x-faster-than-sota-oss-solution/">https://blog.lmcache.ai/en/2025/03/06/open-source-llm-inference-cluster-performing-10x-faster-than-sota-oss-solution/&lt;/a>.&lt;/li>
&lt;li>llm-d Router, código y documentación de operaciones (pesos por defecto, dimensionamiento del EPP, aviso de activo-activo): &lt;a href="https://github.com/llm-d/llm-d-router">https://github.com/llm-d/llm-d-router&lt;/a>.&lt;/li>
&lt;li>llm-d, guía de enrutado preciso por prefijo y resultados publicados (parte interesada): &lt;a href="https://github.com/llm-d/llm-d">https://github.com/llm-d/llm-d&lt;/a>.&lt;/li>
&lt;li>llm-d Router, incidencia 1290, estado de prefijo no compartido entre réplicas del EPP: &lt;a href="https://github.com/llm-d/llm-d-router/issues/1290">https://github.com/llm-d/llm-d-router/issues/1290&lt;/a>.&lt;/li>
&lt;li>Red Hat, &lt;em>Same 16 GPUs, twice the users&lt;/em> (parte interesada): &lt;a href="https://www.redhat.com/en/blog/same-16-gpus-twice-users-inference-aware-routing-llm-clusters">https://www.redhat.com/en/blog/same-16-gpus-twice-users-inference-aware-routing-llm-clusters&lt;/a>.&lt;/li>
&lt;li>Kubernetes SIG Network, Gateway API Inference Extension (traslado del EPP y del BBR): &lt;a href="https://github.com/kubernetes-sigs/gateway-api-inference-extension">https://github.com/kubernetes-sigs/gateway-api-inference-extension&lt;/a>.&lt;/li>
&lt;li>NVIDIA Dynamo, diseño y ajuste del router KV: &lt;a href="https://github.com/ai-dynamo/dynamo">https://github.com/ai-dynamo/dynamo&lt;/a>.&lt;/li>
&lt;li>SGLang, &lt;code>sgl-model-gateway&lt;/code>: &lt;a href="https://github.com/sgl-project/sglang/tree/main/sgl-model-gateway">https://github.com/sgl-project/sglang/tree/main/sgl-model-gateway&lt;/a>.&lt;/li>
&lt;li>AIBrix, enrutado por caché de prefijo: &lt;a href="https://github.com/vllm-project/aibrix/blob/main/pkg/plugins/gateway/algorithms/prefix_cache_readme.md">https://github.com/vllm-project/aibrix/blob/main/pkg/plugins/gateway/algorithms/prefix_cache_readme.md&lt;/a>.&lt;/li>
&lt;li>Ray Serve LLM, enrutado consciente de prefijo (API en alfa): &lt;a href="https://docs.ray.io/en/latest/serve/llm/user-guides/prefix-aware-routing.html">https://docs.ray.io/en/latest/serve/llm/user-guides/prefix-aware-routing.html&lt;/a>.&lt;/li>
&lt;li>Huang Cheng (Meta), &lt;em>CacheRoute&lt;/em>, agosto de 2026: &lt;a href="https://arxiv.org/html/2608.19677">https://arxiv.org/html/2608.19677&lt;/a>.&lt;/li>
&lt;li>Yuan et al., &lt;em>DualMap&lt;/em>, febrero de 2026: &lt;a href="https://arxiv.org/abs/2602.06502">https://arxiv.org/abs/2602.06502&lt;/a>.&lt;/li>
&lt;li>Ricci Toniolo et al., &lt;em>GORGO&lt;/em>, junio de 2026: &lt;a href="https://arxiv.org/html/2602.11688">https://arxiv.org/html/2602.11688&lt;/a>.&lt;/li>
&lt;li>Yoshimura, Chiba y van de Beek, &lt;em>Accuracy Is Speed&lt;/em> (EuroMLSys &amp;lsquo;26), abril de 2026: &lt;a href="https://arxiv.org/html/2604.15732v1">https://arxiv.org/html/2604.15732v1&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Humans and agents on the same gateway: why `priority` does not prioritise, the 429 that brings down the whole pool, and the two ways to separate traffic</title><link>https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/</link><pubDate>Thu, 10 Sep 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/</guid><description>&lt;blockquote>
&lt;p>Fourth article in the operational track of the control layer. The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">pair with Langfuse&lt;/a> covered observability, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the proxy&amp;rsquo;s day 2&lt;/a> covered availability and &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a> covered governance. Here comes the new tenant: agents, which speak the same protocol as humans and behave in a different way. Everything that follows is verified against LiteLLM 1.100.1 and vLLM 0.29.0, both from the first ten days of September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The day a team connects an agent to the corporate gateway, the platform changes regime without anyone touching a YAML. These are the seven conclusions.&lt;/p>
&lt;p>&lt;strong>LiteLLM&amp;rsquo;s &lt;code>priority&lt;/code> parameter prioritises nothing under normal conditions.&lt;/strong> It is still marked as beta and its queue is only consulted when &lt;strong>all&lt;/strong> the deployments in the group are in cooldown. With free capacity, the only thing it produces is a response header. On top of that, the success branch does not pop the item off the queue, so the list grows indefinitely; and on &lt;code>/chat/completions&lt;/code> the value &lt;code>0&lt;/code>, which is the one used by all the documentation examples, is falsy in Python and disables queuing.&lt;/p>
&lt;p>&lt;strong>Separating traffic is done with two pools, not with priorities.&lt;/strong> The non-forgeable way is &lt;code>model_group_alias&lt;/code> at key or team level: the client always asks for &lt;code>qwen-30b&lt;/code> and the credential decides whether that resolves to &lt;code>qwen-30b-interactivo&lt;/code> or to &lt;code>qwen-30b-agentes&lt;/code>. The way that does not touch clients is &lt;code>tag_regex&lt;/code> against the &lt;code>User-Agent&lt;/code>, with a pattern like &lt;code>^User-Agent: claude-code\/&lt;/code>, which serves to classify load and not to control access, because that header is set by the client.&lt;/p>
&lt;p>&lt;strong>The useful admission control arrives in the 1.101 branch&lt;/strong>, with &lt;code>max_in_flight_requests_per_worker&lt;/code> and a per-process queue that returns 503 with &lt;code>retry-after&lt;/code>. It rejects before authenticating, so what it discards does not appear attributed to any key.&lt;/p>
&lt;p>&lt;strong>&lt;code>max_parallel_requests&lt;/code> is only applied at virtual key level.&lt;/strong> The team, user, end customer and organisation endpoints accept the field and store it in the database, and nobody reads it. &lt;code>global_max_parallel_requests&lt;/code> is a no-op unless the old limiter is reactivated, and &lt;code>general_settings.max_parallel_requests&lt;/code> is read by no code path.&lt;/p>
&lt;p>&lt;strong>vLLM with &lt;code>--scheduling-policy priority&lt;/code> does not push ahead anyone who is already generating.&lt;/strong> The only thing that changes is who it evicts when KV cache runs short. If the agents have filled &lt;code>max_num_seqs&lt;/code>, a chat with the highest priority waits just the same. And the two knobs that in 2025 allowed short prompts to get ahead of long ones, &lt;code>max_num_partial_prefills&lt;/code> and &lt;code>max_long_partial_prefills&lt;/code>, disappeared from the code in 0.27.&lt;/p>
&lt;p>&lt;strong>The retry product reaches 45 calls per agent turn.&lt;/strong> Three from the client SDK times three from the router times up to five fallback groups. The proxy sets &lt;code>max_retries=0&lt;/code> on the SDK it uses itself, but it cannot touch the client&amp;rsquo;s. The timeout is per attempt, not per request, and LiteLLM&amp;rsquo;s &lt;code>Timeout&lt;/code> comes out as a 408, which is retryable.&lt;/p>
&lt;p>&lt;strong>A single 429 takes a deployment out of the pool for five seconds&lt;/strong>, without going through the &lt;code>allowed_fails&lt;/code> threshold. With an agentic burst against a fleet of four replicas, the whole pool enters cooldown and the gateway returns a 429 of its own to everybody, humans included.&lt;/p>
&lt;h2 id="you-are-here-the-gateway-layer-with-two-classes-of-traffic">You are here: the gateway layer with two classes of traffic&lt;/h2>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a> this still lives in the gateway layer, with one difference compared to the previous three articles. There the variable was load: more requests, more teams, more budget to share out. Here the variable is the shape of the load.&lt;/p>
&lt;p>A gateway sized for three hundred engineers using a chat works. The same gateway, with thirty of those engineers running coding agents, serves a fraction of the human traffic with the previous latency and the rest degrades. The GPUs stay just as busy, the utilisation metrics stay good, and the support team gets complaints from people waiting nine seconds for a paragraph.&lt;/p>
&lt;h2 id="the-analogy-the-registry-desk-and-the-courier-with-two-hundred-files">The analogy: the registry desk and the courier with two hundred files&lt;/h2>
&lt;p>A registry office serves people in order of arrival. Citizens come in one at a time, present a piece of paper, and leave. The system is fair and the queue moves.&lt;/p>
&lt;p>One Tuesday a courier turns up with a trolley of two hundred files. He joins the queue like anyone else, because rules are rules, and when his turn comes he occupies the desk for forty minutes. Behind him are twelve people with one piece of paper each. Nobody has broken any rule. The courier has the same right as everyone else and uses the same procedure. What fails is that the office was designed assuming a certain size of transaction and another one three orders of magnitude bigger has turned up.&lt;/p>
&lt;p>The real solutions for an office are three, and all three have an exact equivalent in the platform. Open a desk for bulk deliveries, which is separating pools. Put up a capacity sign and send people back later, which is admission control with its 503. And say that files are handed in during the afternoon, which is the part that on an inference platform can almost never be applied, because the agent does not negotiate schedules.&lt;/p>
&lt;p>What does not work in the office is giving the citizen a priority number when the courier is already at the desk. That number does not interrupt the transaction in progress. It is, literally, what priority does in the two layers of the platform, and it is worth keeping in mind while reading the next section.&lt;/p>
&lt;h2 id="the-two-kinds-of-traffic-measured">The two kinds of traffic, measured&lt;/h2>
&lt;p>Before configuring anything, you have to be able to state the difference in numbers, because every later adjustment comes out of it.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Trait&lt;/th>
&lt;th>Human chat&lt;/th>
&lt;th>Agentic loop&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Input per request&lt;/td>
&lt;td>hundreds of tokens&lt;/td>
&lt;td>tens of thousands, and it grows with each turn&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Output per request&lt;/td>
&lt;td>hundreds of tokens&lt;/td>
&lt;td>from two tokens (a tool call) to several thousand&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Concurrency per user&lt;/td>
&lt;td>1&lt;/td>
&lt;td>3 to 20 requests in flight&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The metric that hurts&lt;/td>
&lt;td>time to first token&lt;/td>
&lt;td>total turn throughput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tolerance for waiting&lt;/td>
&lt;td>seconds&lt;/td>
&lt;td>minutes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cancelled requests&lt;/td>
&lt;td>rare&lt;/td>
&lt;td>routine, by the agent&amp;rsquo;s own decision&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Retries&lt;/td>
&lt;td>the browser&amp;rsquo;s&lt;/td>
&lt;td>the SDK&amp;rsquo;s, plus the loop&amp;rsquo;s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Witness when it fails&lt;/td>
&lt;td>the user&lt;/td>
&lt;td>nobody&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two rows deserve comment because they contradict intuition.&lt;/p>
&lt;p>The first is the metric row. An agent does not perceive time to first token, so on a shared engine it is the natural candidate to give up that metric. The operational conclusion is that the agent pool can be configured for aggressive throughput, with large batches and deep queues, while the interactive pool is configured for the opposite.&lt;/p>
&lt;p>The second is the cancellations row. An agent that launches three speculative continuations and discards two generates two aborted streams per turn. At the gateway, that touches spend accounting, the release of concurrency counters and the open connection to the engine. LiteLLM 1.100 handles it reasonably well, and it did not six months ago.&lt;/p>
&lt;h2 id="what-does-not-work-priority-in-litellm">What does not work: &lt;code>priority&lt;/code> in LiteLLM&lt;/h2>
&lt;p>The first reaction of any operator is to look for a priority field. LiteLLM has one, documents it on its &lt;em>Request Prioritization&lt;/em> page, and that page carries the beta label and the sentence saying it is for testing. The label is correct and has to be taken literally, because behind it there are four things.&lt;/p>
&lt;p>The queue is only consulted when the whole group is in cooldown. The scheduler&amp;rsquo;s &lt;code>poll()&lt;/code> method returns true if there is any healthy deployment, without touching the queue. With available capacity, which is the normal situation, the request goes straight through and priority orders nothing. The only thing that changes is that the response carries the &lt;code>x-litellm-request-prioritization-used&lt;/code> header.&lt;/p>
&lt;p>The queue does not empty. In that success branch there is no &lt;code>heappop&lt;/code> and no call to the cleanup, which only happens in the expiry branch. The list under the key &lt;code>scheduler:queue:{model}&lt;/code> grows without bound, is serialised whole into Redis on every insertion, and there is no maximum size and no discard policy. When the group does enter cooldown, the head of the heap is occupied by fossil entries from requests that finished hours ago, and new ones compare their identifier against that head, never match, and wait until the timeout runs out.&lt;/p>
&lt;p>On &lt;code>/chat/completions&lt;/code>, the highest priority disables the feature. The code reads the value with a &lt;code>kwargs.get(&amp;quot;priority&amp;quot;) or self.default_priority&lt;/code>, and in Python &lt;code>0 or None&lt;/code> is &lt;code>None&lt;/code>. Since in this scheduler the lowest number is the highest priority, &lt;code>priority: 0&lt;/code>, which is the value in the documentation&amp;rsquo;s own examples, goes down the path without queuing. On &lt;code>/completions&lt;/code> the check is done with &lt;code>is not None&lt;/code> and it works; the two routes do not behave the same.&lt;/p>
&lt;p>And &lt;code>default_priority&lt;/code> in &lt;code>router_settings&lt;/code> breaks the request. If the client does not send a priority but the global setting is in place, the value is truthy, the scheduling branch is entered and a function is invoked that requires the positional argument nobody has injected. The setting has no associated test and does not appear in the &lt;code>router_settings&lt;/code> reference.&lt;/p>
&lt;p>There is also a &lt;code>/queue/chat/completions&lt;/code>, marked as experimental and hidden from the OpenAPI schema, which reads &lt;code>data[&amp;quot;priority&amp;quot;]&lt;/code> with no default value: a request without that field raises an exception that comes out as a 400 authentication error, with a message that has no relation to the cause.&lt;/p>
&lt;p>There is a priority feature that is solid, and it is a different thing. The &lt;strong>v3 dynamic limiter&lt;/strong> shares out token and request per minute quota between classes with weights, through &lt;code>litellm.priority_reservation&lt;/code> with values like &lt;code>{&amp;quot;premium&amp;quot;: 0.75, &amp;quot;standard&amp;quot;: 0.25}&lt;/code>. It shares out capacity, it does not order a queue: below the saturation threshold it lends free capacity and above it applies the weights. It never makes anyone wait, either it passes or it returns a 429. It requires Postgres and an enterprise licence, and it is still marked as beta.&lt;/p>
&lt;h2 id="what-does-work-two-pools">What does work: two pools&lt;/h2>
&lt;p>Effective separation is done earlier, by deciding which set of replicas each class of traffic goes to. LiteLLM offers two mechanisms with very different security properties.&lt;/p>
&lt;h3 id="by-credential-model_group_alias-at-key-or-team-level">By credential: &lt;code>model_group_alias&lt;/code> at key or team level&lt;/h3>
&lt;p>Router settings are resolved in the order key, team, global. Among those that accept that override is &lt;code>model_group_alias&lt;/code>, and there lies the piece:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-interactivo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-chat.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">32768&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">131072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the agent team&amp;rsquo;s key:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -X POST &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PROXY&lt;/span>&lt;span class="s2">/key/generate&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="nv">$MASTER_KEY&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;team_id&amp;#34;: &amp;#34;plataforma-agentes&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;models&amp;#34;: [&amp;#34;qwen-30b&amp;#34;],
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;router_settings&amp;#34;: {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;model_group_alias&amp;#34;: {&amp;#34;qwen-30b&amp;#34;: &amp;#34;qwen-30b-agentes&amp;#34;}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The client asks for &lt;code>qwen-30b&lt;/code> and knows nothing about any of it. The resolution is tied to the credential, so the caller cannot get around it by changing a header. The same list allows &lt;code>routing_strategy&lt;/code>, &lt;code>fallbacks&lt;/code>, &lt;code>context_window_fallbacks&lt;/code>, &lt;code>retry_policy&lt;/code>, &lt;code>cooldown_time&lt;/code> and &lt;code>allowed_fails&lt;/code> to be set per key or team, which gives different reliability policies for each class of traffic on the same proxy instance.&lt;/p>
&lt;p>It should not be confused with &lt;strong>access groups&lt;/strong> (&lt;code>model_info.access_groups&lt;/code>), which are permissions and not routing: they decide whether a key can invoke a model name, not which replica the request goes to.&lt;/p>
&lt;h3 id="by-user-agent-tag_regex">By &lt;code>User-Agent&lt;/code>: &lt;code>tag_regex&lt;/code>&lt;/h3>
&lt;p>When separate credentials cannot be issued, because the agentic client uses the same personal key as that person&amp;rsquo;s chat, LiteLLM classifies by regular expression against the request headers:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag_regex&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;^User-Agent: claude-code\\/&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;^User-Agent: .*codex&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-01}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-chat.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tags&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat-01}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_tag_filtering&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag_filtering_match_any&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The proxy composes strings of the form &lt;code>User-Agent: &amp;lt;value&amp;gt;&lt;/code> and tests them with &lt;code>re.search&lt;/code>. Exact tag matching takes precedence over the regex. The documentation itself carries a warning that has to be respected: the &lt;code>User-Agent&lt;/code> is written by the client, so this is traffic classification and not a security boundary. As load sharing it is enough, because an agent that lies about its identity to sneak into the interactive pool is a governance problem, not a routing one.&lt;/p>
&lt;p>The behaviour when no deployment carries the requested tag has a nuance that comes as a surprise in production. If the tag is unknown to the group, the request falls through to the pool marked with &lt;code>tags: [&amp;quot;default&amp;quot;]&lt;/code>. If the tag exists in the group but no healthy deployment carries it at that moment, the request &lt;strong>fails&lt;/strong> with the tag configuration error, even though a default pool is available. To get deterministic fallthrough to the default pool you have to set &lt;code>model_info.allow_fail_open: true&lt;/code>.&lt;/p>
&lt;h3 id="a-different-strategy-per-pool-with-routing_groups">A different strategy per pool with &lt;code>routing_groups&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_groups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">group_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">interactivo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">models&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">qwen-30b-interactivo]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">group_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">models&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">qwen-30b-agentes]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The recommendation to leave &lt;code>simple-shuffle&lt;/code> on both is not laziness, and sustaining it requires looking at what the alternatives do on a homogeneous vLLM fleet.&lt;/p>
&lt;p>&lt;code>least-busy&lt;/code> keeps a complete dictionary under a single key and updates it with non-atomic reads and writes, without using &lt;code>INCR&lt;/code>, so with several proxy replicas increments are lost. It has no TTL, so a request that never completes leaves the counter raised forever and that replica is permanently excluded. Counters can go negative. And when the minimum corresponds to an identifier that is no longer in the healthy list, it falls back to &lt;code>random.choice&lt;/code> without warning. The pattern of requests that do not complete is exactly that of an agent that cancels.&lt;/p>
&lt;p>&lt;code>latency-based-routing&lt;/code> measures seconds per output token, not wall-clock latency. A chat turn of twenty tokens looks extremely slow by that metric and an agent generation of two thousand tokens looks extremely fast, so mixing both kinds of traffic in the same group poisons the signal. It also seeds new deployments with zero latency, and with the buffer defaulting to zero, a freshly started replica takes all the traffic until its first success callback.&lt;/p>
&lt;p>&lt;code>usage-based-routing-v2&lt;/code> uses minute windows that reset all at once, and it accounts for tokens when the request completes, so long generations in flight are invisible to the router. With long outputs, it systematically underestimates the real load.&lt;/p>
&lt;p>&lt;code>cost-based-routing&lt;/code> sorts by the sum of the input and output unit prices and takes the first, with no random tie-break. With replicas of the same model at the same price, all the traffic always goes to the same place. And a model that is not in the price map gets a default cost of 5.0 per token, a figure designed to deprioritise it.&lt;/p>
&lt;h2 id="admission-control-the-new-piece-in-the-1101-branch">Admission control: the new piece in the 1.101 branch&lt;/h2>
&lt;p>When the agent pool saturates, what you want is to reject fast and with a code the client understands, not to accumulate requests inside the proxy until memory runs out. That arrives with per-worker admission control, added in the 1.101 branch, in release candidate as this is written.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is a semaphore with a per-uvicorn-process queue, without Redis. When the queue fills or the wait expires it returns &lt;strong>503&lt;/strong> with &lt;code>retry-after: 1&lt;/code> and a body with &lt;code>&amp;quot;type&amp;quot;: &amp;quot;overloaded_error&amp;quot;&lt;/code>. It is observed through &lt;code>/health/backlog&lt;/code>, which exposes requests in flight, admitted, queued and rejected, and through the Prometheus &lt;code>litellm_admission_*&lt;/code> metrics, whose rejection counter distinguishes &lt;code>queue_full&lt;/code> from &lt;code>queue_timeout&lt;/code>.&lt;/p>
&lt;p>Two properties have to go into the runbook. The middleware &lt;strong>rejects before authenticating&lt;/strong>, so what is discarded is not attributed to any key in the spend records, and the real number of requests rejected per team cannot be reconstructed from the spend table. And the limits are read on the first request: changing them requires a restart.&lt;/p>
&lt;h2 id="concurrency-per-key-and-the-three-settings-that-do-nothing">Concurrency per key, and the three settings that do nothing&lt;/h2>
&lt;p>&lt;code>max_parallel_requests&lt;/code> is the right brake for a specific agent, and its configuration surface is full of dead ends.&lt;/p>
&lt;p>The only level where it is applied is the &lt;strong>virtual key&lt;/strong>. The v3 limiter, active by default since 1.94, has descriptors for user, team, team member, end customer, organisation, model per key, tag and agent, and all of them carry only request and token counters per window. The only reference to &lt;code>max_parallel_requests&lt;/code> is the one in the key descriptor. The old limiter says so in a comment: supporting it for model, user and team is pending.&lt;/p>
&lt;p>At the same time, &lt;code>/team/new&lt;/code>, &lt;code>/user/new&lt;/code>, &lt;code>/customer/new&lt;/code> and the organisation endpoints accept the field, validate it and write it to Postgres, and the documentation shows it in their examples. Nobody reads it afterwards. A team with &lt;code>max_parallel_requests: 20&lt;/code> in its row has no concurrency limit at all.&lt;/p>
&lt;p>&lt;code>global_max_parallel_requests&lt;/code> is injected into the request metadata and is only consulted by the old limiter, which is disabled unless &lt;code>LEGACY_MULTI_INSTANCE_RATE_LIMITING=true&lt;/code> is set. The server settings documentation still describes it as a global limit coordinated through Redis. And &lt;code>general_settings.max_parallel_requests&lt;/code> does not appear in any execution path: its only two appearances are the copy from the database and the type schema of the interface.&lt;/p>
&lt;p>What does work, in detail:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -X POST &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PROXY&lt;/span>&lt;span class="s2">/key/generate&amp;#34;&lt;/span> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="nv">$MASTER_KEY&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{&amp;#34;team_id&amp;#34;:&amp;#34;plataforma-agentes&amp;#34;,&amp;#34;max_parallel_requests&amp;#34;:8,&amp;#34;rpm_limit&amp;#34;:600}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Counting is done with a sorted set in Redis scored by Redis&amp;rsquo;s own clock, with Lua scripts to acquire and release. Each slot expires after &lt;strong>3,600 seconds&lt;/strong>, which is at once the maximum request duration the meter can track and the time a leaked slot takes to heal by itself. Release is hooked to success, to failure and to client disconnection during streaming, which was the classic leak with clients that cancel.&lt;/p>
&lt;p>One live leak remains and it affects exactly the case in this article: &lt;strong>every tool call through the MCP gateway acquires a slot and does not release it&lt;/strong>. The MCP route invokes the pre-call hook, which acquires, and none of the release functions is called from that module. With an agent that makes twenty tool calls per turn and a key with eight slots, the 429 arrives on the first turn and lasts an hour.&lt;/p>
&lt;p>The order of evaluation matters for interpreting 429s. First the window counters, requests and tokens per minute, are checked, and only if they pass is the concurrency slot acquired. The code comment explains why: the other way round, every rate limit rejection would leave an orphan slot. In practice, with a low &lt;code>rpm&lt;/code> you always see the &lt;code>requests&lt;/code> type 429, and the &lt;code>max_parallel_requests&lt;/code> one only appears when the relationship between concurrency and duration makes it binding first. The message distinguishes the two cases with the &lt;code>Limit type:&lt;/code> field and the &lt;code>rate_limit_type&lt;/code> header.&lt;/p>
&lt;p>A minor detail with consequences on the client: the response to a concurrency rejection carries &lt;code>retry-after&lt;/code> and &lt;code>reset_at&lt;/code> computed from the window size, sixty seconds by default, when a concurrency meter has no window. A well-behaved client that respects that header will wait a minute when it might have had a free slot in two seconds.&lt;/p>
&lt;h2 id="session-affinity-the-kv-cache-that-gets-thrown-away">Session affinity: the KV cache that gets thrown away&lt;/h2>
&lt;p>An agent turn resends the whole conversation context, system prompt and tools included. If that request lands on a different replica from the previous turn&amp;rsquo;s, vLLM&amp;rsquo;s prefix cache does not have the prefix and the prefill is recomputed in full. With twenty turns and four replicas balanced blindly, the long prefill is paid for fifteen times over.&lt;/p>
&lt;p>LiteLLM has two mechanisms for this and they are not equally good.&lt;/p>
&lt;p>The &lt;code>prompt_caching&lt;/code> check computes a SHA-256 of the cacheable prefix, defined as everything up to the last block with &lt;code>cache_control&lt;/code>, and pins the deployment with that hash. It has two problems. The TTL is hardcoded to 300 seconds in two places in the code, with no way to configure it, something which is reported and open; with one-hour caches, affinity is lost after five minutes of inactivity. And the key is an exact hash, so a client that moves the cut point forward on every turn, which is exactly what agents do to extend the cached prefix, generates a new key every time.&lt;/p>
&lt;p>The &lt;code>session_affinity&lt;/code> check is the one to use:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">optional_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;session_affinity&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deployment_affinity_ttl_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It pins the deployment with an atomic Lua script in Redis, with a configurable TTL that defaults to one hour and that &lt;strong>is refreshed on every request&lt;/strong>, so it bounds the idle time between turns and not the duration of the conversation. If the pinned deployment enters cooldown, the request goes out through the normal strategy and keeps the pin so it can come back when it heals.&lt;/p>
&lt;p>The session identifier comes from a precedence chain that starts with &lt;code>x-litellm-trace-id&lt;/code>, continues with &lt;code>x-litellm-session-id&lt;/code> and then accepts &lt;strong>any header of the form &lt;code>x-&amp;lt;something&amp;gt;-session-id&lt;/code>&lt;/strong> whose value looks like an identifier. That includes the session header Claude Code already sends, with nothing configured on the client. Whatever matches populates both the session identifier and the trace identifier, which links this to the correlation from &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the first article in the track&lt;/a>.&lt;/p>
&lt;p>One dangerous interaction when combining it with tags: affinity is evaluated &lt;strong>before&lt;/strong> tag-based routing. If the pin reduces the list to a deployment that does not satisfy the request&amp;rsquo;s tag, the request fails instead of falling through to the default pool, unless &lt;code>allow_fail_open&lt;/code> has been enabled. Retagging a pool with live sessions breaks them.&lt;/p>
&lt;p>And a clarification that saves a pointless search: &lt;strong>LiteLLM does not do prefix routing&lt;/strong>. There is no prefix tree and nothing that directs the request to the vLLM replica that already has that prefix warm. What there is, is affinity by session or by key. For the other thing you need a specific router underneath the gateway, and that architecture has its own article pending.&lt;/p>
&lt;h2 id="the-engine-what-vllm-can-and-cannot-do">The engine: what vLLM can and cannot do&lt;/h2>
&lt;p>Everything above shares out requests between engines. Inside one engine, the question is what happens when a twenty-token request and a sixty-thousand-token one live side by side.&lt;/p>
&lt;p>&lt;strong>Priority exists and does less than it looks like.&lt;/strong> &lt;code>--scheduling-policy priority&lt;/code> turns the waiting queue into a heap ordered by priority, arrival time and identifier, with the lowest number first. The request can carry the &lt;code>priority&lt;/code> field in the body, and there is also the &lt;code>X-Vllm-Priority&lt;/code> header, which takes precedence over the body and is not documented anywhere.&lt;/p>
&lt;p>What priority does not do is push ahead of whoever is already generating. In the running queue loop, when KV cache runs short, the policy only changes &lt;strong>who is chosen as the victim&lt;/strong> of eviction: with &lt;code>priority&lt;/code> the one with the worst priority and arrival time is evicted, and with &lt;code>fcfs&lt;/code> the last in the queue. In the waiting queue loop, if there are no blocks available the iteration is cut short, without evicting anyone. And the &lt;code>max_num_seqs&lt;/code> cap is a hard cut. If the agents have filled that cap, a chat with maximum priority waits just the same. There has been an open issue since April and an unmerged pull request about this.&lt;/p>
&lt;p>That nuance about the victim is not negligible, because with &lt;code>fcfs&lt;/code> the victims are the most recently admitted, that is, the chats that have just come in. Switching the priority policy on already improves the situation even if it pushes nobody ahead.&lt;/p>
&lt;p>&lt;strong>Eviction is always by recomputation.&lt;/strong> The swap-to-host-memory mode and the enumeration that controlled it disappeared from the code; all that is left is freeing blocks and returning the request to the queue with the computed token counter at zero. An evicted request loses all its prefill, and the prefix cache recovers part of it, but the perceived time to first token restarts.&lt;/p>
&lt;p>Two admission settings help and do not appear in the narrative documentation. &lt;code>watermark&lt;/code> reserves a fraction of free KV blocks on admission, with the declared purpose of avoiding repeated eviction, and it ships disabled. And &lt;code>scheduler_reserve_full_isl&lt;/code>, active by default, checks that the full input length fits in the cache before admitting, instead of looking only at the first chunk.&lt;/p>
&lt;p>&lt;strong>Chunked prefill is active by default and prioritises decode.&lt;/strong> The scheduler walks the whole running request loop before touching the waiting queue, so a long agent prefill does not freeze a chat&amp;rsquo;s generation in progress. That protects the inter-token latency of what has already been admitted, and it does not protect the time to first token of what is waiting.&lt;/p>
&lt;p>Here there is a breaking change that forces a review of the manifests. &lt;strong>&lt;code>max_num_partial_prefills&lt;/code> and &lt;code>max_long_partial_prefills&lt;/code> were removed in 0.27.&lt;/strong> They were precisely the mechanism that allowed short prompts to get ahead of long ones. A deployment with those flags in its &lt;code>args&lt;/code> does not start. &lt;code>long_prefill_token_threshold&lt;/code> still exists, and its meaning changed: today it is a per-request chunk size cap, not a classification of long requests.&lt;/p>
&lt;p>The partial replacement arrives in 0.29 with &lt;code>max_num_queued_tokens&lt;/code> and &lt;code>max_num_queued_reqs&lt;/code>, admission valves that return &lt;strong>503&lt;/strong> when the pending prefill work exceeds the target. The code itself frames it as a quality of service mechanism for time to first token, with the formula of setting it to the latency target multiplied by prefill throughput. They are blind to the class of traffic: they reject a chat just the same as an agent.&lt;/p>
&lt;p>And the default value of &lt;code>--max-num-batched-tokens&lt;/code> depends on the hardware and on the start-up mode. With &lt;code>vllm serve&lt;/code> on H100 or H200 it is 8,192, on cards of 160 GiB or more it is 16,384, and on the rest 2,048. The 2,048 that appears in the scheduler configuration only applies to tests.&lt;/p>
&lt;p>The metrics for diagnosing coexistence, with the exact names from 0.29:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>What for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/td>
&lt;td>queue depth, the autoscaling signal&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_waiting_by_reason&lt;/code>&lt;/td>
&lt;td>separates waiting for capacity from waiting in a blocked state&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:num_preemptions_total&lt;/code>&lt;/td>
&lt;td>evictions, the signal that the two classes are treading on each other&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:kv_cache_usage_perc&lt;/code>&lt;/td>
&lt;td>cache pressure, the cause of the evictions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:request_queue_time_seconds&lt;/code>&lt;/td>
&lt;td>what a chat waits with the agents inside&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:prefix_cache_hits_total&lt;/code> and &lt;code>_queries_total&lt;/code>&lt;/td>
&lt;td>measured in tokens, not in requests&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A warning for anyone with old alerts. The log warning about evicted sequences that appears in the optimisation documentation belongs to the previous version of the engine and &lt;strong>no longer exists in the code&lt;/strong>. Preemptions are only visible as a counter. An alert based on searching for that string in the logs has not fired for months, and not because there are no evictions.&lt;/p>
&lt;p>On isolation between classes inside one engine, the short answer is that it does not exist. There are two queues and neither is per class of traffic. The conclusion, which is the one that holds up this article&amp;rsquo;s architecture: effective isolation means &lt;strong>separate fleets&lt;/strong>, and on top of them the gateway deciding who goes to which.&lt;/p>
&lt;h2 id="retries-the-product-nobody-calculates">Retries: the product nobody calculates&lt;/h2>
&lt;p>A human who gets an error sees it and decides. An agentic loop retries, and beneath it there are two more layers that also retry.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Default&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Client SDK&lt;/td>
&lt;td>2 retries, 3 attempts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM router&lt;/td>
&lt;td>2 retries, 3 attempts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fallbacks&lt;/td>
&lt;td>up to 5 groups&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The product is about &lt;strong>45 calls to the engine for one agent turn&lt;/strong>, before counting the loop&amp;rsquo;s own retry. LiteLLM does what it can: when the call comes from the router, it forces &lt;code>max_retries=0&lt;/code> on the SDK it itself uses against the engine. What it cannot touch is the client&amp;rsquo;s SDK. The only layer that breaks the product is that one, and it is broken by setting &lt;code>max_retries=0&lt;/code> on the client and delegating the retry to the gateway.&lt;/p>
&lt;p>The retryable errors are 408, 409, 429 and anything 500 or above. And there lies the problem with long reasoning: &lt;code>litellm.Timeout&lt;/code> is a subclass of OpenAI&amp;rsquo;s timeout exception and comes out with &lt;strong>code 408&lt;/strong>, which is retryable, so the most expensive request of all, the one that expired after ten minutes of generating, is launched again in full. Twice by the router, and as many times again by the client.&lt;/p>
&lt;p>On timeouts you have to be precise because the code comment is: the configured timeout is &lt;strong>per attempt, not per request&lt;/strong>. A &lt;code>request_timeout: 600&lt;/code> with two retries and two fallback groups gives a worst case of almost two hours before the client receives anything. The default value is still 6,000 seconds, as was already covered in &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the day 2 article&lt;/a>.&lt;/p>
&lt;p>The retry storm has two specific causes and both are default settings.&lt;/p>
&lt;p>The first: &lt;strong>a single 429 takes the deployment out of the pool&lt;/strong>, without going through the &lt;code>allowed_fails&lt;/code> threshold. The cooldown logic has an explicit branch which, on a 429 in a group with more than one deployment, returns immediate cooldown. With an agentic burst against four replicas, all four leave the pool at once and the gateway answers everybody with its own 429 of &amp;ldquo;no deployments available&amp;rdquo;. The default cooldown is five seconds, which in a sustained burst gets renewed.&lt;/p>
&lt;p>The second: &lt;strong>backoff is zero while the pool looks healthy&lt;/strong>. The function that computes the wait returns 0 if the healthy deployment list is not empty. Exponential backoff with jitter, capped at eight seconds, only kicks in when there is nothing healthy left. Under partial degradation, the proxy hammers away without waiting and only slows down once everything has already fallen over.&lt;/p>
&lt;p>On top of that, the engine&amp;rsquo;s &lt;code>Retry-After&lt;/code> header is only respected if its value is between 1 and 60 seconds. A longer limit window is ignored and replaced by the proxy&amp;rsquo;s own backoff.&lt;/p>
&lt;p>The settings that cut the problem off:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retry_after&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># real floor; the default 0 is hammering&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldown_time&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allowed_fails_policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">RateLimitErrorAllowedFails&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># stops a 429 taking the deployment out&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retry_policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">TimeoutErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># do not relaunch the expensive request&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">AuthenticationErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">900&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># per attempt&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">drop_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That last setting deserves an explanation, because it is a silent way of breaking an agent. With &lt;code>drop_params: true&lt;/code>, LiteLLM removes from the body any parameter the destination provider does not support, and in that branch it writes no warning at all. The candidates for disappearing are &lt;code>tools&lt;/code>, &lt;code>tool_choice&lt;/code>, &lt;code>response_format&lt;/code> and &lt;code>parallel_tool_calls&lt;/code>. An agent that has &lt;code>tools&lt;/code> taken away receives free text where it expected a function call, and interprets it as the model having decided not to use tools.&lt;/p>
&lt;p>Worse still is prompt-based emulation. For a non-OpenAI-compatible provider that does not support tools, LiteLLM does not fail: it turns the tools into text inside the prompt, forces JSON format, discards &lt;code>tool_choice&lt;/code>, and on the way switches on a &lt;strong>process-global variable&lt;/strong> that makes any other tool-less provider in the same process go to emulation instead of erroring.&lt;/p>
&lt;p>With that in mind, the warning about fallbacks is clear: &lt;strong>LiteLLM does not check capabilities before falling back&lt;/strong>. The function that knows whether a model supports function calling exists and is only used in an informational endpoint. The pre-call checks filter by context window, request limit and region, and nothing else. A fallback list that goes from a model with tools to one without them degrades the agent silently. There are guards for provider-scoped resources, files and batches, so the absence of the capability guard is an omission and not a design oversight.&lt;/p>
&lt;h2 id="a-months-budget-in-twenty-minutes">A month&amp;rsquo;s budget in twenty minutes&lt;/h2>
&lt;p>A runaway agent consumes in minutes what a team consumes in weeks, and the operational question is how long the gateway takes to find out.&lt;/p>
&lt;p>The answer has changed, and the old mental model, the one where spend consolidates every minute and until then there is no brake, is no longer correct. Since 1.84 there are two new pieces. An &lt;strong>optimistic reservation before the call&lt;/strong>, which estimates the maximum cost of the request and reserves it atomically in the counter before heading out to the engine, reconciling it with the real cost on completion. And a counter increment after the call that is explicitly awaited, with the comment that this way the counter is up to date before the next request goes through authentication. Budget checks read that counter, with Redis first and seeding from the database when cold.&lt;/p>
&lt;p>Batch writing to Postgres still exists, every 10 to 15 seconds, and it affects persistence and what is shown in the interface, not the cut-off. The production documentation&amp;rsquo;s recommendation to raise that interval to 60 does not relax the control, and it does delay by a minute what &lt;code>/key/info&lt;/code> shows, something which frequently generates operational panic. And the budget rescheduler&amp;rsquo;s ten minutes do not consolidate spend: they fire the job that resets expired budgets.&lt;/p>
&lt;p>The overspend window then comes down to the cost of the requests in flight, provided there is Redis. &lt;strong>Without Redis, the counters are per pod&lt;/strong>, and with N replicas you can spend up to N times the budget. That is the configuration failure that matters and it is the same Redis that &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the day 2 article&lt;/a> declared non-optional for other reasons.&lt;/p>
&lt;p>Two settings that do not appear in the public documentation, only in the data model descriptions, and that decide behaviour at the worst moment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fail_closed_budget_enforcement: true # false by default&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">if the counter fails, it lets things through&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># disable_budget_reservation: do not set it to true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With the default value, if the counter backend does not respond, the proxy &lt;strong>fails open&lt;/strong> and carries on serving. For an agent in a loop that is exactly the opposite of what you want.&lt;/p>
&lt;p>Time for the honest warning: this route is still unstable. There are open issues in both directions, one where the key and user budget limit stops being applied while spend is recorded, and another where a 429 is returned for stale spend in the reservation counters while the key query shows spend below the limit. Project budgets are not on the atomic reservation route and concurrency overshoots them. The practical conclusion is not to trust the hard cut-off to the budget alone.&lt;/p>
&lt;p>On the manual cut-off, there is an asymmetry to know about before you need it. &lt;code>/key/block&lt;/code> invalidates the local cache, the Redis one, and notifies all workers by publish, so with Redis the effect is almost immediate. &lt;code>/customer/block&lt;/code> does the same. &lt;strong>&lt;code>/team/block&lt;/code> only writes to the database&lt;/strong>, without invalidating or notifying, so it takes however long the management object cache TTL takes, sixty seconds by default. To cut a team off dead you have to block its keys one by one, or lower &lt;code>user_api_key_cache_ttl&lt;/code>. And &lt;code>/user/block&lt;/code> no longer exists, even though the docstring of the function that replaced it still shows that route in its example.&lt;/p>
&lt;p>On alerts, two numbers. The default thresholds warn at &lt;strong>85 % and 95 %&lt;/strong> of the budget. And deduplication of those alerts is &lt;strong>24 hours&lt;/strong> per event and identifier, so crossing 85 % generates one warning and then silence for a day. For agentic traffic, where 85 % and 100 % can be twenty minutes apart, the second warning arrives when it is no longer any use.&lt;/p>
&lt;h2 id="attribution-who-the-user-is-when-the-caller-is-an-agent">Attribution: who the user is when the caller is an agent&lt;/h2>
&lt;p>With agents, the question of who the spend is charged to has two answers and both are needed: the agent that executes and the human on whose behalf it acts.&lt;/p>
&lt;p>The chain that extracts the end customer identifier tries the standard headers first, then the configurable header mapping, and only then the &lt;code>user&lt;/code> field of the body, the &lt;code>litellm_metadata.user&lt;/code> of the Anthropic format, the &lt;code>metadata.user_id&lt;/code> and the &lt;code>safety_identifier&lt;/code>. The last four are body fields and are therefore written by the client. The code itself warns about it: for untrusted callers the identifier has to be set from headers or from a middleware on the server, to avoid impersonation.&lt;/p>
&lt;p>Out of that comes the correct pattern, which is the inverse of the one people put together out of inertia: &lt;strong>the virtual key identifies the agent&lt;/strong> and is the non-forgeable part, and the header or the &lt;code>user&lt;/code> field identify the human and serve for attribution, not for control. An agent that lies about the human behind it is still bounded by the budget and the limits of its key.&lt;/p>
&lt;p>The spend table stores &lt;code>user&lt;/code>, &lt;code>end_user&lt;/code>, &lt;code>team_id&lt;/code>, &lt;code>organization_id&lt;/code>, &lt;code>request_tags&lt;/code>, &lt;code>session_id&lt;/code> and &lt;code>agent_id&lt;/code>, with daily aggregation per agent, so the detail is available once it is sent. The &lt;code>x-litellm-agent-id&lt;/code> header is what populates that last field.&lt;/p>
&lt;p>A note about the MCP gateway, which on a platform with agents ends up being the other entry door. Tool calls &lt;strong>do appear in the spend records&lt;/strong>, with call types of their own and a metadata block that stores tool name, arguments, result and server. What is missing is token counting: the cost is &lt;strong>0.0 by default&lt;/strong> and is only populated if the administrator configures a price per query, per server or per tool. It is the same pattern of silent zero cost that already came up with unpriced models in &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the first article in the track&lt;/a>.&lt;/p>
&lt;p>And the permission setting to review on day one: access control for MCP servers is &lt;strong>open by default&lt;/strong>. If no level of the hierarchy defines a list, the request reaches every configured server. &lt;code>general_settings.require_key_mcp_access_defined: true&lt;/code> inverts that inheritance, and it is not the default value.&lt;/p>
&lt;h2 id="a-reference-configyaml">A reference &lt;code>config.yaml&lt;/code>&lt;/h2>
&lt;p>All of the above, together, on a fleet split into two pools:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-interactivo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-chat.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000018&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, silent zero cost&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">32768&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># without this there is no context filter&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000018&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag_regex&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;^User-Agent: claude-code\\/&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">131072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allow_fail_open&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_tag_filtering&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">optional_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;session_affinity&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deployment_affinity_ttl_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retry_after&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldown_time&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allowed_fails_policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">RateLimitErrorAllowedFails&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retry_policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">TimeoutErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">AuthenticationErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fail_closed_budget_enforcement&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">require_key_mcp_access_defined&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_api_key_cache_ttl&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># 1.101 branch onwards:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">900&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># per attempt&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">drop_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Redis, not optional&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the start-up of the two engines, with the deliberate asymmetry:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># interactive pool: protect the first token&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen3-30B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --scheduling-policy priority &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">64&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">32768&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">4096&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># agent pool: throughput and deep queues&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen3-30B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">256&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">131072&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">8192&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Issue separate keys for agents, with &lt;code>model_group_alias&lt;/code> at key or team level. It is the only separation the client cannot evade.&lt;/li>
&lt;li>Add &lt;code>tag_regex&lt;/code> over the &lt;code>User-Agent&lt;/code> as a safety net for agents that use personal credentials, with a &lt;code>default&lt;/code> pool and &lt;code>allow_fail_open: true&lt;/code>.&lt;/li>
&lt;li>Leave &lt;code>simple-shuffle&lt;/code> on both pools. The other four strategies have verifiable defects on homogeneous replicas.&lt;/li>
&lt;li>Enable &lt;code>session_affinity&lt;/code> with a one-hour TTL. Do not use &lt;code>prompt_caching&lt;/code> for this while its TTL stays hardcoded.&lt;/li>
&lt;li>Declare &lt;code>max_input_tokens&lt;/code> on every deployment. Without that value, the context window filter is not applied and it fails silently for models that are not in the price map.&lt;/li>
&lt;li>Declare per-token prices on every self-hosted model, or the agents&amp;rsquo; spend will be zero and their budget will never run out.&lt;/li>
&lt;li>&lt;code>max_parallel_requests&lt;/code> on the agent&amp;rsquo;s key, never on its team. On the team it is stored and not applied.&lt;/li>
&lt;li>Lower &lt;code>num_retries&lt;/code> to 1, put a floor on &lt;code>retry_after&lt;/code>, raise &lt;code>cooldown_time&lt;/code> and disable retry on timeout. On the client, &lt;code>max_retries=0&lt;/code>.&lt;/li>
&lt;li>&lt;code>fail_closed_budget_enforcement: true&lt;/code> and mandatory Redis. Without Redis, N replicas are N times the budget.&lt;/li>
&lt;li>Start the interactive engine with &lt;code>--scheduling-policy priority&lt;/code> even though it pushes nobody ahead: it changes who it evicts.&lt;/li>
&lt;li>Alert on &lt;code>vllm:num_preemptions_total&lt;/code> and &lt;code>vllm:num_requests_waiting_by_reason&lt;/code>, not on log strings that are no longer emitted.&lt;/li>
&lt;li>Prepare the cut-off: block by key, never by team, and verify the real propagation time in your own deployment.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>&lt;code>priority: 0&lt;/code> on &lt;code>/chat/completions&lt;/code> disables prioritisation.&lt;/strong> It is the value in the documentation examples and it is falsy in Python.&lt;/li>
&lt;li>&lt;strong>&lt;code>max_parallel_requests&lt;/code> on a team is stored and not applied.&lt;/strong> The field exists in the API, in the database and in the documentation; the limiter only reads it from the key descriptor.&lt;/li>
&lt;li>&lt;strong>&lt;code>global_max_parallel_requests&lt;/code> is a no-op&lt;/strong> with the default limiter, and the server settings documentation still describes it as active.&lt;/li>
&lt;li>&lt;strong>Every MCP call leaks a concurrency slot&lt;/strong>, which only heals when its hour expires. With agents calling tools in a loop, the 429 appears early and lasts a long time.&lt;/li>
&lt;li>&lt;strong>The TTL of prompt cache affinity is 300 hardcoded seconds.&lt;/strong> For agent conversations, session affinity is the right option.&lt;/li>
&lt;li>&lt;strong>Affinity is evaluated before tag-based routing.&lt;/strong> A pin to a deployment that stops satisfying the tag makes the request fail instead of falling through to the default pool.&lt;/li>
&lt;li>&lt;strong>LiteLLM&amp;rsquo;s timeout is per attempt.&lt;/strong> The worst case is the timeout multiplied by attempts and by fallback groups.&lt;/li>
&lt;li>&lt;strong>A 429 takes the deployment out of the pool without going through &lt;code>allowed_fails&lt;/code>&lt;/strong>, and backoff is zero while anything healthy remains.&lt;/li>
&lt;li>&lt;strong>The engine&amp;rsquo;s &lt;code>Retry-After&lt;/code> is ignored if it asks for more than 60 seconds.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>&lt;code>drop_params: true&lt;/code> removes &lt;code>tools&lt;/code> and &lt;code>response_format&lt;/code> without leaving a warning&lt;/strong>, and the fallback does not check whether the destination model supports function calling.&lt;/li>
&lt;li>&lt;strong>&lt;code>/team/block&lt;/code> does not invalidate the cache.&lt;/strong> It takes the full TTL; &lt;code>/key/block&lt;/code> does propagate by publish in Redis.&lt;/li>
&lt;li>&lt;strong>Access to MCP servers is open by default&lt;/strong> if no level defines a list.&lt;/li>
&lt;li>&lt;strong>&lt;code>max_num_partial_prefills&lt;/code> and &lt;code>max_long_partial_prefills&lt;/code> no longer exist in vLLM.&lt;/strong> A manifest with those flags does not start from 0.27 onwards.&lt;/li>
&lt;li>&lt;strong>vLLM&amp;rsquo;s priority does not push ahead of running requests&lt;/strong>, and with &lt;code>max_num_seqs&lt;/code> full it pushes nobody ahead.&lt;/li>
&lt;li>&lt;strong>The eviction log warning that appears in the vLLM documentation is no longer emitted.&lt;/strong> Only the Prometheus counter is left.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The coexistence of humans and agents on the same platform is not solved with a priority field, and that is the conclusion that takes the most effort to accept because the field exists and looks made for this. Neither the gateway nor the engine has a usable notion of class of service today: one orders a queue that is almost never consulted, the other picks who to evict. What they do have is the ability to direct each request to a different place, and that is where the win is.&lt;/p>
&lt;p>The architecture that comes out of all of the above fits in one sentence. Two vLLM fleets with opposite parameters, a credential that decides which one each request goes to, session affinity so as not to throw away the prefill work, concurrency limits on the agent&amp;rsquo;s key and not on its team, retries trimmed back on the three layers where they multiply, and budgets that fail closed.&lt;/p>
&lt;p>The rest is measurement. If &lt;code>vllm:num_preemptions_total&lt;/code> rises in the interactive pool, the two classes are treading on each other despite the separation. If chat queue time grows while total throughput holds, the partition is badly sized. And if the agents&amp;rsquo; spend comes out as zero, the problem is not coexistence but undeclared prices, which is where this track started.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits in LiteLLM&lt;/a>, the full hierarchy and the v3 limiter that this article&amp;rsquo;s limits rest on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">LiteLLM on day 2: high availability&lt;/a>, the Redis and the Postgres taken for granted here, and retries seen from the gateway side.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a>, the trace correlation that this article&amp;rsquo;s session identifier feeds, and the zero cost of unpriced models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a>, where the figures that go into the budgets defended here come from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents&lt;/a>, the other side of the problem, the agent as a security risk rather than a class of traffic.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">The second cost vector of AI agents&lt;/a>, what a loop that fails halfway costs, and why durable execution changes that sum.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing: what LiteLLM does not do&lt;/a>, the continuation of this article&amp;rsquo;s session affinity, and the correction of what the gateway can do with the KV cache.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>, the agents&amp;rsquo; other entry door, including the correction about the default access this article took to be open.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a>, the router&amp;rsquo;s four functions and the prefix-aware routing LiteLLM does not do.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Prefill optimisations in vLLM&lt;/a>, the work that is recomputed in full when a request is evicted.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The vLLM scheduler step&lt;/a>, the loop the priority policy and chunked prefill operate on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a>, the identity of the other entry door, the one agents use to call tools.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a>, the question before this one: how big the fleet and the gateway have to be, with the agentic load measurements published in 2026.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>, the identity of the agent and of the human seen from outside the proxy.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>[BETA] Request Prioritization&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/scheduler">https://docs.litellm.ai/docs/scheduler&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Router - Load Balancing&lt;/em> (estrategias, afinidad de sesión, comprobaciones previas): &lt;a href="https://docs.litellm.ai/docs/routing">https://docs.litellm.ai/docs/routing&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Tag Based Routing&lt;/em> (incluye &lt;code>tag_regex&lt;/code> y el ejemplo con clientes agénticos): &lt;a href="https://docs.litellm.ai/docs/proxy/tag_routing">https://docs.litellm.ai/docs/proxy/tag_routing&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Router Settings for Keys and Teams&lt;/em> (resolución clave, equipo, global): &lt;a href="https://docs.litellm.ai/docs/proxy/keys_teams_router_settings">https://docs.litellm.ai/docs/proxy/keys_teams_router_settings&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Server Tuning&lt;/em> (control de admisión por worker): &lt;a href="https://docs.litellm.ai/docs/proxy/server_tuning">https://docs.litellm.ai/docs/proxy/server_tuning&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Dynamic TPM/RPM Allocation&lt;/em> (reserva de prioridad, beta y enterprise): &lt;a href="https://docs.litellm.ai/docs/proxy/dynamic_rate_limit">https://docs.litellm.ai/docs/proxy/dynamic_rate_limit&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Budgets, Rate Limits&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/proxy/users">https://docs.litellm.ai/docs/proxy/users&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Production Best Practices&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/proxy/prod">https://docs.litellm.ai/docs/proxy/prod&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Reliability&lt;/em> (fallbacks, ventana de contexto, enfriamientos): &lt;a href="https://docs.litellm.ai/docs/proxy/reliability">https://docs.litellm.ai/docs/proxy/reliability&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código: &lt;code>litellm/scheduler.py&lt;/code>, &lt;code>litellm/router_strategy/tag_based_routing.py&lt;/code>, &lt;code>litellm/router_utils/pre_call_checks/deployment_affinity_check.py&lt;/code>, &lt;code>litellm/proxy/hooks/parallel_request_limiter_v3.py&lt;/code>, &lt;code>litellm/proxy/middleware/admission_control_middleware.py&lt;/code>, &lt;code>litellm/router_utils/cooldown_handlers.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 28427, TTL de la afinidad por caché de prompts fijado a cinco minutos: &lt;a href="https://github.com/BerriAI/litellm/issues/28427">https://github.com/BerriAI/litellm/issues/28427&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 34534, fuga de slot de concurrencia en cada llamada MCP: &lt;a href="https://github.com/BerriAI/litellm/issues/34534">https://github.com/BerriAI/litellm/issues/34534&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 26672, presupuesto de clave y usuario no aplicado: &lt;a href="https://github.com/BerriAI/litellm/issues/26672">https://github.com/BerriAI/litellm/issues/26672&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 872, comprobación previa de soporte de llamada a funciones, cerrada sin implementar: &lt;a href="https://github.com/BerriAI/litellm/issues/872">https://github.com/BerriAI/litellm/issues/872&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Optimization and Tuning&lt;/em> (prefill troceado, prioridad de decodificación): &lt;a href="https://docs.vllm.ai/en/v0.29.0/configuration/optimization.html">https://docs.vllm.ai/en/v0.29.0/configuration/optimization.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Metrics design&lt;/em> (nombres, convención &lt;code>_total&lt;/code>, autoescalado como problema abierto): &lt;a href="https://docs.vllm.ai/en/v0.29.0/design/metrics.html">https://docs.vllm.ai/en/v0.29.0/design/metrics.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Data Parallel Deployment&lt;/em> (caché KV independiente por réplica): &lt;a href="https://docs.vllm.ai/en/v0.29.0/serving/data_parallel_deployment.html">https://docs.vllm.ai/en/v0.29.0/serving/data_parallel_deployment.html&lt;/a>.&lt;/li>
&lt;li>vLLM, código: &lt;code>vllm/v1/core/sched/scheduler.py&lt;/code>, &lt;code>vllm/v1/core/sched/request_queue.py&lt;/code>, &lt;code>vllm/config/scheduler.py&lt;/code>, &lt;code>vllm/engine/arg_utils.py&lt;/code>, &lt;code>vllm/v1/metrics/loggers.py&lt;/code>: &lt;a href="https://github.com/vllm-project/vllm">https://github.com/vllm-project/vllm&lt;/a>.&lt;/li>
&lt;li>vLLM, incidencia 40004, la prioridad no desaloja peticiones en ejecución: &lt;a href="https://github.com/vllm-project/vllm/issues/40004">https://github.com/vllm-project/vllm/issues/40004&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Virtual keys, budgets and limits in LiteLLM: the layer that decides who gets the GPU, and the four things the documentation gets wrong</title><link>https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/</link><pubDate>Tue, 08 Sep 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/</guid><description>&lt;blockquote>
&lt;p>Third article in the operational track of the control layer. The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">pair with Langfuse&lt;/a> covered observability, and &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2 of the proxy&lt;/a> covered availability. Here it is governance: who can call, which model, how much and on what budget. The economic model behind those figures is in &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy with LiteLLM&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>On a platform running on your own GPUs there is no provider invoice to put consumption in order. Capacity is fixed, known and shared, so someone has to impose the allocation, and that someone is the gateway.&lt;/p>
&lt;p>&lt;strong>The hierarchy has six levels&lt;/strong>: organisation, team, team member, internal user, key and end customer, plus the project and tag scopes. Each level&amp;rsquo;s budgets are evaluated &lt;strong>independently and all of them have to pass&lt;/strong>. They neither add up nor cancel each other out, with one exception.&lt;/p>
&lt;p>&lt;strong>The exception is in the code and it is the most important governance trap in the system.&lt;/strong> A key that belongs to a team suppresses the check of its owner&amp;rsquo;s personal budget, unless &lt;code>apply_user_budget_to_team_keys&lt;/code> is enabled. A user with a hundred-euro cap who works with their team&amp;rsquo;s key spends whatever the team allows.&lt;/p>
&lt;p>&lt;strong>Rotating a key preserves its spend.&lt;/strong> &lt;code>/key/regenerate&lt;/code> does an UPDATE on the same row changing the hash, so &lt;code>spend&lt;/code>, &lt;code>max_budget&lt;/code> and the budget window remain intact. It accepts a grace period, stored in a table of its own, and if the period&amp;rsquo;s format is wrong it is ignored without blocking anything.&lt;/p>
&lt;p>&lt;strong>The v3 limiter is already the default&lt;/strong> in 1.100.0, and you go back to the previous one with an environment variable. Its window is sliding, sixty seconds, anchored to the first request, and the counters are incremented with Lua scripts inside Redis using Redis&amp;rsquo;s own timestamp to close the race between replicas.&lt;/p>
&lt;p>And four points where the official documentation does not match the code, verified against tag v1.100.0: &lt;strong>an exhausted budget returns 429&lt;/strong>, not 400; &lt;strong>the audited actions are six&lt;/strong>, not three; &lt;code>/key/info&lt;/code> and &lt;code>/key/list&lt;/code> are &lt;strong>GET&lt;/strong>, not POST; and the v3 limiter carries a docstring saying it is not production-ready while it is the one loaded by default.&lt;/p>
&lt;h2 id="you-are-here-the-gateway-as-an-administrative-boundary">You are here: the gateway as an administrative boundary&lt;/h2>
&lt;p>Without this layer, an in-house inference platform has a single mode of operation: whoever knows the URL consumes. It works while there is one team. It stops working the day there are three, or the day someone connects an agent that fires twenty calls per interaction and wrecks everyone else&amp;rsquo;s time to first token.&lt;/p>
&lt;p>The two questions this layer answers are different and have to be kept apart. The &lt;strong>capacity&lt;/strong> one is instantaneous: how many requests and how many tokens per minute each consumer can push, so the fleet does not saturate and latency stays within the service agreement. The &lt;strong>cost&lt;/strong> one is cumulative: how much each consumer has spent this month against its budget. They are configured in the same place, applied at different moments and fail in different ways.&lt;/p>
&lt;h2 id="the-analogy-the-buildings-key-ring">The analogy: the building&amp;rsquo;s key ring&lt;/h2>
&lt;p>An office building hands out keys at three levels. The master opens everything and the building manager has it. The floor keys open a whole floor and each tenant company has one. And the office keys open one office.&lt;/p>
&lt;p>Three properties of that system are the ones that matter here. A key is replaced without changing the lock of the whole building, and the tenant keeps their parking space and their electricity meter: that is rotation preserving the spend. A lost key is cancelled from the central panel without collecting anything physical: that is blocking from the interface. And the record of who took out each key and when lives in a separate book, which is what you show an inspector: that is the audit table.&lt;/p>
&lt;p>The analogy breaks at one point, and that point is where this system&amp;rsquo;s governance flaw lives. In the building, having a floor key does not cancel the office&amp;rsquo;s consumption limit. In LiteLLM, it does.&lt;/p>
&lt;h2 id="the-hierarchy-and-the-line-that-breaks-it">The hierarchy, and the line that breaks it&lt;/h2>
&lt;p>The levels and their management endpoints:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Level&lt;/th>
&lt;th>Endpoints&lt;/th>
&lt;th>Budget&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Organisation&lt;/td>
&lt;td>&lt;code>/organization/new&lt;/code>, &lt;code>/organization/member_add&lt;/code>, …&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Team&lt;/td>
&lt;td>&lt;code>/team/new&lt;/code>, &lt;code>/team/member_add&lt;/code>, &lt;code>/team/block&lt;/code>, …&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Team member&lt;/td>
&lt;td>&lt;code>/team/member_add&lt;/code> with &lt;code>max_budget_in_team&lt;/code>&lt;/td>
&lt;td>Its own row in the budgets table&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Internal user&lt;/td>
&lt;td>&lt;code>/user/new&lt;/code>, &lt;code>/user/update&lt;/code>, …&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Key&lt;/td>
&lt;td>&lt;code>/key/generate&lt;/code>, &lt;code>/key/update&lt;/code>, …&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>, &lt;code>soft_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>End customer&lt;/td>
&lt;td>&lt;code>/customer/new&lt;/code>, and the parallel &lt;code>/end_user/*&lt;/code> set&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The default behaviour is the right one for a multi-tenant platform: each scope carries its own counter and the check of each one is independent of the rest. A request passes if all of them pass.&lt;/p>
&lt;p>And then there is this condition, which lives in the authorisation checks:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">is_team_key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">team_object&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="kc">None&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">team_object&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">team_id&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">is_team_key&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">general_settings&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;apply_user_budget_to_team_keys&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Translated: when the key belongs to a team, the check of the budget of the user who owns that key &lt;strong>returns without evaluating anything&lt;/strong>. The personal cap exists in the database, is visible in the interface, and is not applied.&lt;/p>
&lt;p>For a deployment where personal budgets are decorative, it makes no difference. For one where the per-person cap is the control that was shown to someone as a guarantee that a user cannot overrun the platform, it is a hole. The line that closes it is one:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">apply_user_budget_to_team_keys&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="virtual-keys-the-complete-life-cycle">Virtual keys: the complete life cycle&lt;/h2>
&lt;p>The endpoints all exist and some of them are not where you expect:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Route&lt;/th>
&lt;th>Method&lt;/th>
&lt;th>What for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>/key/generate&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Create&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/update&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Modify&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/info&lt;/code>&lt;/td>
&lt;td>&lt;strong>GET&lt;/strong>&lt;/td>
&lt;td>Query one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/list&lt;/code>&lt;/td>
&lt;td>&lt;strong>GET&lt;/strong>&lt;/td>
&lt;td>List&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/delete&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Delete&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/regenerate&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Rotate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/block&lt;/code>, &lt;code>/key/unblock&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Suspend and reactivate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/health&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Check that key&amp;rsquo;s callbacks&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The two marked in bold are GET, and several examples in the documentation suggest otherwise. &lt;code>/key/health&lt;/code> is not what its name suggests either: it does not validate the key or its budget, it checks the logging callbacks associated with it.&lt;/p>
&lt;p>There are many creation parameters, and these are the ones that govern:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -X POST &lt;span class="s1">&amp;#39;https://gateway.interno/key/generate&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s1">&amp;#39;Authorization: Bearer sk-...&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;key_alias&amp;#34;: &amp;#34;equipo-datos-notebooks&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;team_id&amp;#34;: &amp;#34;t-datos&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;models&amp;#34;: [&amp;#34;llama-70b&amp;#34;, &amp;#34;qwen-30b&amp;#34;],
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;max_budget&amp;#34;: 250,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;budget_duration&amp;#34;: &amp;#34;30d&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;tpm_limit&amp;#34;: 400000,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;rpm_limit&amp;#34;: 600,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;max_parallel_requests&amp;#34;: 8,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;duration&amp;#34;: &amp;#34;90d&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;tags&amp;#34;: [&amp;#34;produccion&amp;#34;, &amp;#34;notebooks&amp;#34;]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Alongside those there are controls that are rarely used and solve specific problems: &lt;code>model_max_budget&lt;/code> to set a per-model cap within the same key, &lt;code>model_tpm_limit&lt;/code> and &lt;code>model_rpm_limit&lt;/code> for the same thing with capacity, &lt;code>enforced_params&lt;/code> to require certain keys to always arrive with given fields, &lt;code>allowed_routes&lt;/code> so an application key cannot touch the management endpoints, and &lt;code>blocked&lt;/code> to be born suspended.&lt;/p>
&lt;h3 id="how-they-are-stored">How they are stored&lt;/h3>
&lt;p>A key&amp;rsquo;s hash is a single-pass hexadecimal SHA-256, with no salt and no derivation function:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">hashed_token&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">hashlib&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sha256&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">hexdigest&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The row lives in &lt;code>LiteLLM_VerificationToken&lt;/code>, with the hash as primary key, and it contains the accumulated spend, the limits, the expiry, the allowed models and the rest of the configuration.&lt;/p>
&lt;p>Two consequences to be clear about before an audit. The first is that this hash is not designed to resist a dictionary attack against the database, and the code itself acknowledges it in a comment about encryption key derivation, where it admits that a single-pass unsalted SHA-256 is not a derivation function and that moving to HKDF would be more defensible in an audit. The real mitigation is that virtual keys are generated with enough entropy, not the strength of the hash. The second is that &lt;code>LITELLM_SALT_KEY&lt;/code> &lt;strong>plays no part here&lt;/strong>: that variable encrypts the stored provider credentials, not the virtual keys.&lt;/p>
&lt;p>There is a trap around that variable that takes down a whole deployment. If it is not defined, it &lt;strong>falls back to the master key&lt;/strong>. Rotating &lt;code>LITELLM_MASTER_KEY&lt;/code> without having first set your own &lt;code>LITELLM_SALT_KEY&lt;/code> leaves every stored provider credential unreadable, and the failure does not block: it logs an error and returns null, so the symptom shows up as models that stop authenticating with no explanation. Setting &lt;code>LITELLM_SALT_KEY&lt;/code> on day one, and never touching it, is one of the things that saves the most grief.&lt;/p>
&lt;h3 id="rotation">Rotation&lt;/h3>
&lt;p>&lt;code>/key/regenerate&lt;/code> does not create a new key and delete the old one. It updates the same row changing the hash, so &lt;strong>the accumulated spend, the maximum budget and the reset window are preserved&lt;/strong>. For a monthly chargeback this is exactly what you want: rotating mid-month does not zero the counter.&lt;/p>
&lt;p>It also accepts a grace period, so the old key remains valid while consumers update:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -X POST &lt;span class="s1">&amp;#39;https://gateway.interno/key/sk-vieja/regenerate&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s1">&amp;#39;Authorization: Bearer sk-...&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{&amp;#34;grace_period&amp;#34;: &amp;#34;24h&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The old hash is stored in a separate table with its revocation date. Without that parameter, revocation is immediate, and the default value can be set with &lt;code>LITELLM_KEY_ROTATION_GRACE_PERIOD&lt;/code>.&lt;/p>
&lt;p>A detail with teeth: if the period&amp;rsquo;s format is wrong, the system logs a warning and &lt;strong>continues with no grace period&lt;/strong>. The rotation completes, the old key dies on the spot, and all that is left is a log line. In an automated rotation procedure, that combination of &amp;ldquo;carry on&amp;rdquo; and &amp;ldquo;warn in the log&amp;rdquo; is what produces the outage at three in the morning.&lt;/p>
&lt;h2 id="what-exactly-happens-when-a-budget-runs-out">What exactly happens when a budget runs out&lt;/h2>
&lt;p>The code raises &lt;code>BudgetExceededError&lt;/code> with &lt;strong>status 429&lt;/strong>. The documentation says 400 in one place, and there the code wins. Its being a 429 has a practical implication: OpenAI clients retry automatically on a 429, so an exhausted budget turns into a retry storm against a gateway that is going to keep rejecting. Internal clients should distinguish the reason, which travels in the message.&lt;/p>
&lt;p>The messages are templates and state the scope, the spend and the cap:&lt;/p>
&lt;pre tabindex="0">&lt;code>Budget has been exceeded! Key=&amp;lt;clave&amp;gt; Current cost: &amp;lt;gasto&amp;gt;, Max budget: &amp;lt;tope&amp;gt;
Budget has been exceeded! Team=&amp;lt;equipo&amp;gt; Current cost: &amp;lt;gasto&amp;gt;, Max budget: &amp;lt;tope&amp;gt;
ExceededBudget: User=&amp;lt;usuario&amp;gt; over budget. Spend=&amp;lt;gasto&amp;gt;, Budget=&amp;lt;tope&amp;gt;
ExceededBudget: End User=&amp;lt;cliente&amp;gt; over budget. Spend=&amp;lt;gasto&amp;gt;, Budget=&amp;lt;tope&amp;gt;
Budget has been exceeded! Organization=&amp;lt;org&amp;gt; ...
Budget has been exceeded! Tag=&amp;lt;etiqueta&amp;gt; ...
&lt;/code>&lt;/pre>&lt;p>Two clarifications about the comparison. It is &lt;code>spend &amp;gt;= cap&lt;/code>, with the equals sign included, so landing exactly on the cap already blocks. And the soft budget, &lt;code>soft_budget&lt;/code>, &lt;strong>blocks nothing&lt;/strong>: it only writes a log line when it is crossed. It serves to warn, not to contain, and building an alert on top of it is the right way to use it.&lt;/p>
&lt;p>The rest of the rejections, with their codes:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Situation&lt;/th>
&lt;th>Code&lt;/th>
&lt;th>Behaviour&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Budget exhausted&lt;/td>
&lt;td>429&lt;/td>
&lt;td>Message with scope, spend and cap&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Expired key&lt;/td>
&lt;td>401&lt;/td>
&lt;td>It also deletes the cache entry, so the expiry takes effect across the whole fleet without waiting for the TTL&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model not allowed&lt;/td>
&lt;td>403&lt;/td>
&lt;td>Different error type depending on the scope: key, team, user, organisation or project&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Blocked key&lt;/td>
&lt;td>Generic exception&lt;/td>
&lt;td>&lt;code>Key is blocked. Update via /key/unblock if you're an admin.&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last row is an inconsistency in the project worth knowing about: blocking raises an exception with no explicit type or code, while expiry and model access do carry them. Any alert that discriminates by error type will not see blocks the way it sees the rest.&lt;/p>
&lt;p>A useful note about allowed models: the check accepts both the alias and the underlying model, so a key authorised for &lt;code>llama-70b&lt;/code> can call it by its alias or by its real name with no additional configuration.&lt;/p>
&lt;h2 id="capacity-limits-the-v3-limiter">Capacity limits: the v3 limiter&lt;/h2>
&lt;p>This is the most significant silent change of the year in this layer. In 1.100.0, the limiter loaded by default is the third version. Going back to the previous one has to be asked for explicitly:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LEGACY_MULTI_INSTANCE_RATE_LIMITING&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>There is no flag to turn it on, only to turn it off. And it carries a contradiction worth knowing before trusting it: the file itself still opens with a comment saying it is under development and not production-ready, while it is the one running. The design is avowedly inspired by Envoy&amp;rsquo;s.&lt;/p>
&lt;p>&lt;strong>The window is sliding and lasts sixty seconds&lt;/strong>, configurable with &lt;code>LITELLM_RATE_LIMIT_WINDOW_SIZE&lt;/code>. It is not an exact rolling log nor a clock-aligned window: it anchors to the first request and resets once the window size has elapsed from that moment. For one-minute limits, the difference from a calendar window shows up at the edges and rarely matters.&lt;/p>
&lt;p>What does matter is Redis. The counters are incremented with Lua scripts executed inside Redis, using Redis&amp;rsquo;s own timestamp and not the pod&amp;rsquo;s, precisely so window resets are deterministic across replicas and to close the race between checking and counting. Without Redis, &lt;strong>each pod counts on its own and the effective limit is multiplied by the number of pods&lt;/strong>. A limit of 600 requests per minute with five replicas is a limit of 3,000.&lt;/p>
&lt;p>There is an inherent imprecision in the token limits that Redis does not solve. The number of output tokens is not known until the response finishes, so the limiter counts with an estimate before the call and reconciles afterwards. The possible excess is, per in-flight request, the difference between the estimate and the real figure. With high concurrency and long responses, that allows the cap to be exceeded within a window. To protect the fleet, the concurrent request limit (&lt;code>max_parallel_requests&lt;/code>) is a more direct instrument than tokens per minute.&lt;/p>
&lt;h3 id="what-the-client-sees">What the client sees&lt;/h3>
&lt;p>When limited, a 429 with the message giving the scope, the current limit, what is left and when it resets. And three headers: &lt;code>retry-after&lt;/code> with the window size, that is &lt;code>60&lt;/code> by default, plus &lt;code>rate_limit_type&lt;/code> and &lt;code>reset_at&lt;/code>.&lt;/p>
&lt;p>On the success path, the informational headers use a nested format of their own:&lt;/p>
&lt;pre tabindex="0">&lt;code>x-ratelimit-api_key-remaining-requests
x-ratelimit-api_key-limit-tokens
x-ratelimit-team-remaining-requests
&lt;/code>&lt;/pre>&lt;p>&lt;strong>They are not the standard names&lt;/strong> &lt;code>x-ratelimit-limit-requests&lt;/code> that OpenAI and compatibles emit, so a client expecting the usual format will find nothing. There is also an open issue about these headers being lost on streaming responses, which is most of an assistant&amp;rsquo;s traffic.&lt;/p>
&lt;h2 id="identity-beyond-the-static-key">Identity: beyond the static key&lt;/h2>
&lt;p>Handing out keys by hand works with three teams and stops working with thirty. The two routes for connecting the gateway to corporate identity:&lt;/p>
&lt;p>&lt;strong>JWT.&lt;/strong> It is enabled with &lt;code>enable_jwt_auth&lt;/code> and configured with the &lt;code>litellm_jwtauth&lt;/code> block, which maps token fields to LiteLLM entities: the team comes from &lt;code>team_id_jwt_field&lt;/code>, the user from &lt;code>user_id_jwt_field&lt;/code>, the organisation from &lt;code>org_id_jwt_field&lt;/code>. It supports dot notation for nested claims, automatic creation of users and teams with &lt;code>user_id_upsert&lt;/code> and &lt;code>team_id_upsert&lt;/code>, role-based access control with &lt;code>enforce_rbac&lt;/code>, per-team model restriction with &lt;code>enforce_team_based_model_access&lt;/code>, and several issuers at once. The issuer&amp;rsquo;s public key is cached with a TTL of 600 seconds by default.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_jwt_auth&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_jwtauth&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">team_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;groups&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sub&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_email_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;email&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_rbac&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_team_based_model_access&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">team_id_upsert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>OIDC for the admin interface.&lt;/strong> With Keycloak you use the generic provider, and the variables are &lt;code>PROXY_BASE_URL&lt;/code>, &lt;code>GENERIC_CLIENT_ID&lt;/code>, &lt;code>GENERIC_CLIENT_SECRET&lt;/code>, &lt;code>GENERIC_AUTHORIZATION_ENDPOINT&lt;/code>, &lt;code>GENERIC_TOKEN_ENDPOINT&lt;/code>, &lt;code>GENERIC_USERINFO_ENDPOINT&lt;/code>, plus a set of attributes to map the identifier, the email, the name and the role, and &lt;code>GENERIC_ROLE_MAPPINGS_*&lt;/code> to translate Keycloak groups into LiteLLM roles. Login is rate-limited and sessions have a TTL of their own.&lt;/p>
&lt;p>For automatic provisioning there is SCIM in the enterprise version, with base &lt;code>/scim/v2&lt;/code>, and deprovisioning a user blocks their keys and takes them out of the authentication cache. That part is not in the open tree, so it cannot be verified against the code.&lt;/p>
&lt;p>This fits with the identity work already covered in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening and secrets of the sovereign stack&lt;/a> and in the article on &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">MCP authentication with Keycloak&lt;/a>, which uses the same issuer.&lt;/p>
&lt;h2 id="audit-what-gets-recorded">Audit: what gets recorded&lt;/h2>
&lt;p>The table is &lt;code>LiteLLM_AuditLog&lt;/code> and its columns are what you would expect from a change log:&lt;/p>
&lt;pre tabindex="0">&lt;code>id, updated_at, changed_by, changed_by_api_key,
action, table_name, object_id, before_value, updated_values
&lt;/code>&lt;/pre>&lt;p>It stores the previous value and the new one, and attributes the change to an actor and to the key it was made with.&lt;/p>
&lt;p>Three clarifications the documentation does not capture well.&lt;/p>
&lt;p>&lt;strong>The actions are six, in the past tense&lt;/strong>: &lt;code>created&lt;/code>, &lt;code>updated&lt;/code>, &lt;code>deleted&lt;/code>, &lt;code>blocked&lt;/code>, &lt;code>unblocked&lt;/code> and &lt;code>rotated&lt;/code>. The documentation mentions three. That blocking, unblocking and rotation are actions in their own right is exactly what is needed to reconstruct a credential&amp;rsquo;s life cycle for an auditor.&lt;/p>
&lt;p>&lt;strong>The scope is wider than advertised.&lt;/strong> Besides teams, users, keys and models, changes to the proxy configuration and to the SSO configuration are audited. A change of identity issuer is recorded.&lt;/p>
&lt;p>&lt;strong>The default depends on the licence.&lt;/strong> The resolution order is &lt;code>litellm_settings.store_audit_logs&lt;/code>, then the variable &lt;code>LITELLM_STORE_AUDIT_LOGS&lt;/code>, and if neither is set, it stays &lt;strong>enabled in enterprise and disabled in the open version&lt;/strong>. An OSS deployment that assumes there is an audit trail does not have one:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">store_audit_logs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two more pieces for a serious chain of evidence. The actor attribution can be passed with the &lt;code>LiteLLM-Changed-By&lt;/code> header, and its use is restricted by the key&amp;rsquo;s or the team&amp;rsquo;s configuration, so any caller cannot forge it. And the events can be shipped out with &lt;code>audit_log_callbacks&lt;/code> and its S3 parameters, which is what allows depositing them in an immutable store with object lock.&lt;/p>
&lt;p>That last part is what turns the record into evidence. The Postgres table is mutable by anyone with database access, and as of September 2026 it has no indexes, so querying it by date range over a long history will not be comfortable either.&lt;/p>
&lt;h2 id="what-this-demonstrates-to-an-auditor-and-what-it-does-not">What this demonstrates to an auditor, and what it does not&lt;/h2>
&lt;p>This is the part that decides whether the setup is good enough for a regulated customer. With everything above configured, the platform sustains without difficulty:&lt;/p>
&lt;p>&lt;strong>Access control via corporate identity&lt;/strong>, with onboarding and offboarding in the identity provider and not in the gateway, and with the role determining which models are reachable. &lt;strong>Segregation between tenants&lt;/strong>, with independent counters per organisation, team and person. &lt;strong>Traceability of administrative changes&lt;/strong>, with actor, time, previous value and new value, exportable to an immutable store. And &lt;strong>demonstrable consumption limits&lt;/strong>, with the evidence of the rejection in the spend log.&lt;/p>
&lt;p>What it does not cover, and has to be solved elsewhere:&lt;/p>
&lt;p>&lt;strong>The record of the interactions&lt;/strong> is not this. The audit table records configuration changes, not model calls. The calls are in the spend log and in the traces, and traces are best-effort, which is the discussion in the &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">first article in the series&lt;/a>.&lt;/p>
&lt;p>&lt;strong>The integrity of the record&lt;/strong> is not something Postgres provides. Without export to immutable storage, the evidence is only as strong as the database permissions.&lt;/p>
&lt;p>&lt;strong>The content of the prompts&lt;/strong> is a separate problem, with its own personal-data treatment and its own decision about whether it is stored, masked or not logged at all.&lt;/p>
&lt;p>And a warning about the control that tends to be shown first: while &lt;code>apply_user_budget_to_team_keys&lt;/code> is not enabled, the per-person cap is not being applied to team keys. Showing that field in the interface as proof of a control that is not executed is the kind of finding a competent auditor discovers. The complete control mapping is in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">ENS, ISO 42001 and the EU AI Act&lt;/a>.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>&lt;code>apply_user_budget_to_team_keys: true&lt;/code> if personal budgets have to be enforced.&lt;/li>
&lt;li>&lt;code>LITELLM_SALT_KEY&lt;/code> set on day one, different from the master key and never rotated lightly.&lt;/li>
&lt;li>&lt;code>store_audit_logs: true&lt;/code> explicitly, without trusting the default.&lt;/li>
&lt;li>&lt;code>audit_log_callbacks&lt;/code> towards an immutable store if an audit has to be sustained.&lt;/li>
&lt;li>Redis mandatory if there is more than one replica, or the limits get multiplied by the number of pods.&lt;/li>
&lt;li>&lt;code>max_parallel_requests&lt;/code> per key, on top of the per-minute limits, to protect the fleet from a runaway agent.&lt;/li>
&lt;li>&lt;code>allowed_routes&lt;/code> on application keys, so they cannot reach the management endpoints.&lt;/li>
&lt;li>&lt;code>soft_budget&lt;/code> with an alert on top, understanding that it does not block.&lt;/li>
&lt;li>Rotation with a verified &lt;code>grace_period&lt;/code>, because an invalid format revokes on the spot.&lt;/li>
&lt;li>JWT against Keycloak as soon as there are more than a handful of teams, with &lt;code>enforce_rbac&lt;/code> and &lt;code>enforce_team_based_model_access&lt;/code>.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>A user&amp;rsquo;s cap is not enforced.&lt;/strong> They are using a team key and &lt;code>apply_user_budget_to_team_keys&lt;/code> is missing.&lt;/p>
&lt;p>&lt;strong>Clients retry in a loop against an exhausted budget.&lt;/strong> The code is 429 and the OpenAI libraries retry 429s automatically.&lt;/p>
&lt;p>&lt;strong>The soft budget has stopped nothing.&lt;/strong> It does not block, it only logs.&lt;/p>
&lt;p>&lt;strong>The per-minute limits are enforced at double or triple.&lt;/strong> There is no Redis, and each replica keeps its own count.&lt;/p>
&lt;p>&lt;strong>The client does not see the limit headers.&lt;/strong> The names are nested and proprietary, and in streaming there is an open issue where they get lost.&lt;/p>
&lt;p>&lt;strong>There is no audit log.&lt;/strong> It is an OSS deployment and the default there is disabled.&lt;/p>
&lt;p>&lt;strong>The models stop authenticating after rotating the master key.&lt;/strong> There was no fixed &lt;code>LITELLM_SALT_KEY&lt;/code>, and the encrypted provider credentials can no longer be decrypted.&lt;/p>
&lt;p>&lt;strong>The rotation cut the service on the spot.&lt;/strong> The &lt;code>grace_period&lt;/code> carried a format the system did not understand and it carried on without it.&lt;/p>
&lt;p>&lt;strong>Blocking a key does not fire the alert.&lt;/strong> It is raised as a generic exception, without the type that expiry and model access do carry.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The keys and budgets layer is the one that turns a shared URL into a platform with tenants. It is well solved in LiteLLM and has more depth than the documentation suggests, with per-model budgets inside a key, multiple budget windows, per-tag limits and rotation that preserves the history.&lt;/p>
&lt;p>Out of the whole article, two things take the attention. The &lt;code>apply_user_budget_to_team_keys&lt;/code> line, because it is the difference between having a control and believing you have one. And setting &lt;code>LITELLM_SALT_KEY&lt;/code>, because its default turns a routine rotation of the master key into an outage that is hard to diagnose. Both are one line of configuration and both are discovered late.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a> — what happens to these limits when the caller is an agentic loop, and why &lt;code>max_parallel_requests&lt;/code> is only applied on the key.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — where the traces of all this end up, and why the audit record cannot lean on them.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">LiteLLM on day 2: high availability&lt;/a> — the Redis taken as given here, and the Postgres arithmetic that sustains the spend table.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a> — where the figures that go into &lt;code>max_budget&lt;/code> come from, and the difference between showback and chargeback.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">From GPU-hour to cost per token and per request&lt;/a> — the conversion that gives meaning to a budget in euros on your own GPU.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS, ISO 42001 and the EU AI Act&lt;/a> — the complete mapping, of which this article covers the access control and administrative traceability part.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign stack&lt;/a> — the management of &lt;code>LITELLM_SALT_KEY&lt;/code> and of the rest of the deployment&amp;rsquo;s secrets.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a> — the same identity issuer, applied to the platform&amp;rsquo;s other front door.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> — the alternative to these keys, and why a long-lived key decouples identity from the provider.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Virtual Keys&lt;/em> (parámetros de creación, ciclo de vida, rotación): &lt;a href="https://docs.litellm.ai/docs/proxy/virtual_keys">https://docs.litellm.ai/docs/proxy/virtual_keys&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Budgets, Rate Limits&lt;/em> (jerarquía, presupuestos por nivel, clientes finales): &lt;a href="https://docs.litellm.ai/docs/proxy/users">https://docs.litellm.ai/docs/proxy/users&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>JWT Token Auth&lt;/em> (bloque &lt;code>litellm_jwtauth&lt;/code> y todos sus campos): &lt;a href="https://docs.litellm.ai/docs/proxy/token_auth">https://docs.litellm.ai/docs/proxy/token_auth&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Audit Logs&lt;/em> y administración múltiple (la cabecera &lt;code>LiteLLM-Changed-By&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/multiple_admins">https://docs.litellm.ai/docs/proxy/multiple_admins&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>SCIM&lt;/em> (aprovisionamiento automático, versión enterprise): &lt;a href="https://docs.litellm.ai/docs/tutorials/scim_litellm">https://docs.litellm.ai/docs/tutorials/scim_litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/proxy/auth/auth_checks.py&lt;/code> en la etiqueta v1.100.0 (la condición de &lt;code>apply_user_budget_to_team_keys&lt;/code> y los mensajes de presupuesto agotado): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/auth/auth_checks.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/auth/auth_checks.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/proxy/management_endpoints/key_management_endpoints.py&lt;/code> en v1.100.0 (rutas, rotación sobre la misma fila, periodo de gracia): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/management_endpoints/key_management_endpoints.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/management_endpoints/key_management_endpoints.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/proxy/hooks/parallel_request_limiter_v3.py&lt;/code> y &lt;code>litellm/proxy/hooks/__init__.py&lt;/code> en v1.100.0 (el limitador v3 como valor por defecto, la ventana, los scripts Lua): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/hooks/parallel_request_limiter_v3.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/hooks/parallel_request_limiter_v3.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/exceptions.py&lt;/code> en v1.100.0 (&lt;code>BudgetExceededError&lt;/code> con código 429): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/exceptions.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/exceptions.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/proxy/common_utils/encrypt_decrypt_utils.py&lt;/code> en v1.100.0 (&lt;code>LITELLM_SALT_KEY&lt;/code> cayendo en la clave maestra, y el comentario sobre la derivación de claves): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/common_utils/encrypt_decrypt_utils.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/common_utils/encrypt_decrypt_utils.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>schema.prisma&lt;/code> en v1.100.0 (&lt;code>LiteLLM_VerificationToken&lt;/code>, &lt;code>LiteLLM_AuditLog&lt;/code>, la tabla de claves deprecadas): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/schema.prisma">https://github.com/BerriAI/litellm/blob/v1.100.0/schema.prisma&lt;/a>.&lt;/li>
&lt;li>LiteLLM, issue #27748 — cabeceras &lt;code>x-ratelimit-*&lt;/code> perdidas en respuestas en streaming con el limitador v3: &lt;a href="https://github.com/BerriAI/litellm/issues/27748">https://github.com/BerriAI/litellm/issues/27748&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>LiteLLM on day 2: one worker per pod, six thousand seconds of timeout and the other five things to change before opening the traffic</title><link>https://blog.lo0.es/en/posts/litellm-day-2-high-availability/</link><pubDate>Tue, 08 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-day-2-high-availability/</guid><description>&lt;blockquote>
&lt;p>Second article in the operational track of the control layer. The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">operational pair with Langfuse&lt;/a> covered the seam between the gateway and observability; here everything stays inside the gateway. The prior decisions, which gateway and why, are in &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">choosing the OSS gateway&lt;/a> and in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 inference router&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>LiteLLM Proxy starts from a twenty-line &lt;code>config.yaml&lt;/code> and serves traffic that same afternoon. The operational work consists of correcting seven defaults designed for a scenario other than an in-house inference factory.&lt;/p>
&lt;p>&lt;strong>One uvicorn worker per pod.&lt;/strong> The project&amp;rsquo;s production guide asks for &lt;code>--num_workers 1&lt;/code> on Kubernetes and horizontal scaling, with &lt;strong>1 vCPU and 4 GiB per worker set at the same time as &lt;code>requests&lt;/code> and as &lt;code>limits&lt;/code>&lt;/strong>. The 4 GiB are a floor, not a target: the Prisma query engine marks a resident memory high-water mark that grows up to the largest statement it has executed and that glibc does not return to the system.&lt;/p>
&lt;p>&lt;strong>Autoscale on CPU, never on memory.&lt;/strong> A direct consequence of the above. Memory goes up and never down, so an HPA on memory scales and never scales back. The recommended target is &lt;code>targetCPUUtilizationPercentage: 60&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The Postgres connection arithmetic.&lt;/strong> &lt;code>database_connection_pool_limit&lt;/code> is 10, and the number of connections is instances times workers times the pool. The Helm charts ship &lt;code>maxReplicas: 100&lt;/code>, on the order of a thousand connections, far above what a stock Postgres accepts.&lt;/p>
&lt;p>&lt;strong>Redis stops being optional&lt;/strong> from around a thousand requests per second or ten instances. Without it, each instance applies its limits on its own, caches are not shared, and spend updates against the same rows produce locks and exhaust the connections with &lt;code>FATAL: sorry, too many clients already&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Three health endpoints and only two are usable as probes.&lt;/strong> &lt;code>/health/liveliness&lt;/code> and &lt;code>/health/readiness&lt;/code> require no authentication and answer fast. Bare &lt;code>/health&lt;/code> demands a key and fires a real request against every model in the catalogue, with its cost in tokens.&lt;/p>
&lt;p>&lt;strong>The resilience numbers are 2, 3 and 5&lt;/strong>: two retries, three allowed failures and five seconds of cooldown, the last one despite the function&amp;rsquo;s own documentation saying the default is 1.&lt;/p>
&lt;p>&lt;strong>&lt;code>request_timeout&lt;/code> is 6,000 seconds.&lt;/strong> A hundred minutes holding a connection against an engine that does not answer.&lt;/p>
&lt;p>And a warning about the performance figures: the project publishes 8 ms of p95 overhead on one benchmark and 257.7 ms of p99 on another. Both are theirs, and the difference is in the shape of the load.&lt;/p>
&lt;h2 id="you-are-here-the-gateway-layer-the-day-after">You are here: the gateway layer, the day after&lt;/h2>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a>, this article lives entirely in the gateway layer. The difference with the selection article is the one that separates buying from maintaining: there the question was which piece to put in front of the fleet, and here it is what happens when that piece has been serving for six months, the team has tripled the traffic and someone has added an agent that fires twenty calls per interaction.&lt;/p>
&lt;p>The gateway has an uncomfortable property worth keeping in mind: it sits on the critical path of every request and contributes not a single token. All it does is coordination, and any coordination that fails turns into an outage of the whole inference service, even with the GPUs perfectly healthy.&lt;/p>
&lt;h2 id="the-analogy-the-switchboard-and-its-notebook">The analogy: the switchboard and its notebook&lt;/h2>
&lt;p>A hospital switchboard does three jobs at once. It puts calls through, which is the urgent part. It writes down in a notebook who called, to which extension and for how long, which is what sustains the billing. And it consults a list of extensions to know where to put each call through.&lt;/p>
&lt;p>The three jobs compete. If the operator stops to write each line in the notebook before putting the next call through, the waiting queue grows. If there are three operators and each keeps her own notebook, the totals do not add up. And if the list of extensions is consulted in a filing cabinet on another floor, every call costs the trip.&lt;/p>
&lt;p>The three tensions have an exact equivalent in LiteLLM. The notebook is the &lt;code>SpendLogs&lt;/code> in Postgres, which is why the transaction buffer in Redis exists. The three operators with three notebooks are the pod replicas applying limits independently. And the filing cabinet on another floor is usage-based routing, which adds a Redis lookup inside the request path.&lt;/p>
&lt;h2 id="the-process-model-one-worker-per-pod">The process model: one worker per pod&lt;/h2>
&lt;p>The production documentation is explicit: on Kubernetes, &lt;code>--num_workers 1&lt;/code> and scale with replicas. On a virtual machine with no orchestrator, &lt;code>NUM_WORKERS&lt;/code> equal to the number of vCPUs.&lt;/p>
&lt;p>The reason is not ideological. An additional worker inside the same pod shares the container&amp;rsquo;s memory limit with the others, multiplies the database connections by the same factor, and registers a copy of every periodic job. With separate replicas, each one has its own resource budget, its own connection quota and a place where the scheduler can put it.&lt;/p>
&lt;p>The recommended sizing is &lt;strong>1 vCPU and 4 GiB per worker&lt;/strong>, and the part that gets overlooked is that these go as &lt;code>requests&lt;/code> and as &lt;code>limits&lt;/code> at the same time. Setting less than 4 GiB produces an OOM restart loop that shows up late, once some large request has grown the query engine&amp;rsquo;s footprint.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">, memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">, memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That footprint needs to be described precisely, because it determines the autoscaling policy. The Prisma query engine keeps a resident memory that behaves as a high-water mark: it grows up to the largest statement the process has executed and does not come back down, because glibc does not return that freed memory to the system. A pod that served a spike yesterday still shows the footprint of that spike today.&lt;/p>
&lt;p>Two rules come out of this. The first: &lt;strong>autoscale on CPU and never on memory&lt;/strong>. An HPA on memory sees a metric that only goes up, scales at the peak and never scales back. The recommended target is &lt;code>targetCPUUtilizationPercentage: 60&lt;/code>, lower than the 80 the charts ship by default, because the startup probe allows up to 300 seconds before passing the first readiness check and there needs to be headroom for the new pod to be ready before the saturated one goes down.&lt;/p>
&lt;p>The second: bound the process lifetime. &lt;code>--max_requests_before_restart 10000&lt;/code> recycles the worker before the high-water mark matters.&lt;/p>
&lt;h2 id="postgres-the-arithmetic-that-blows-up-when-you-scale">Postgres: the arithmetic that blows up when you scale&lt;/h2>
&lt;p>&lt;code>database_connection_pool_limit&lt;/code> is &lt;strong>10&lt;/strong> by default. The total number of connections the deployment opens is replicas times workers times pool, and it has to be compared against the Postgres &lt;code>max_connections&lt;/code>.&lt;/p>
&lt;p>The formula the documentation gives is the inverse, and it is the one to apply when sizing:&lt;/p>
&lt;pre tabindex="0">&lt;code>database_connection_pool_limit = MAX_DB_CONNECTIONS / (instances × workers)
&lt;/code>&lt;/pre>&lt;p>The detail that hurts in production is in the Helm charts, which ship &lt;code>autoscaling.maxReplicas&lt;/code> and &lt;code>keda.maxReplicas&lt;/code> at &lt;strong>100&lt;/strong>. With the default pool, that is on the order of a thousand connections at full scale-out, far above what an untouched Postgres accepts. And the moment the HPA reaches those replicas is exactly the moment of highest load, so connection exhaustion arrives when it can least be afforded.&lt;/p>
&lt;p>There are two more valves on the same path. Provider errors are written to the database by default, and under a sustained engine failure that inflates the spend table without contributing anything that is not already in the metrics. They are switched off like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">disable_error_logs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># stops writing provider errors&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># batches the spend writes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>disable_spend_logs: true&lt;/code> is the radical version, which removes the per-request detail from the interface and leaves the cost in Prometheus and in the tracing backend. Before going that far you have to decide whether that table is the system&amp;rsquo;s audit record, which is the discussion in the &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">previous article&lt;/a>: if it is, it cannot be switched off, and the way out is the Redis buffer.&lt;/p>
&lt;h2 id="redis-when-it-stops-being-optional">Redis: when it stops being optional&lt;/h2>
&lt;p>Without Redis, LiteLLM works. Each instance keeps its own in-memory cache and applies its own counters. The consequences are three, and all of them show up as you grow.&lt;/p>
&lt;p>Requests-per-minute limits are applied per instance, so a limit of 100 with five replicas is in practice a limit of 500. Cache hits are local, so the same repeated question hits once in five. And there is no leader election, so periodic jobs run in every process at once.&lt;/p>
&lt;p>The threshold the documentation gives for enabling the transaction buffer is &lt;strong>a thousand requests per second or ten instances&lt;/strong>. Below that, each instance updates the key, user and team rows directly; above it, they all write to the same rows, locks appear and Postgres starts rejecting with &lt;code>FATAL: sorry, too many clients already&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">use_redis_transaction_buffer&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The metrics to watch when that is enabled are &lt;code>litellm_in_memory_spend_update_queue_size&lt;/code>, &lt;code>litellm_redis_spend_update_queue_size&lt;/code> and &lt;code>litellm_pod_lock_manager_size&lt;/code>. If the first grows without the second coming down, the flush to Postgres is not keeping up.&lt;/p>
&lt;p>A note about the routing strategy, which interacts with this. &lt;code>simple-shuffle&lt;/code> consults nothing external. Usage-based routing does, and it adds a round trip to Redis inside the request path. On a homogeneous vLLM fleet behind the same model, the improvement it brings rarely pays for that latency; on a heterogeneous fleet, the conversation is different and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the router article&lt;/a> covered it.&lt;/p>
&lt;h2 id="background-jobs-register-per-worker">Background jobs register per worker&lt;/h2>
&lt;p>This detail appears in no getting-started guide and produces considerable bewilderment when it is discovered. The proxy&amp;rsquo;s periodic jobs register &lt;strong>per uvicorn worker, not per pod&lt;/strong>. With &lt;code>--num_workers 4&lt;/code> and ten replicas that is forty copies of every job, all of them running with no coordination if there is no Redis for leader election.&lt;/p>
&lt;p>The variable that separates them is &lt;code>LITELLM_JOB_ROLE&lt;/code>: value &lt;code>serving&lt;/code> on the pods that serve traffic and a separate replica with value &lt;code>worker&lt;/code> for the jobs. With the one-worker-per-pod recommendation the problem shrinks, but it does not go away while there are several replicas.&lt;/p>
&lt;h2 id="probes-three-endpoints-and-only-two-are-usable">Probes: three endpoints and only two are usable&lt;/h2>
&lt;p>The three endpoints exist and do different things:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Endpoint&lt;/th>
&lt;th>Authentication&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>What it is for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>/health/liveliness&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Returns &lt;code>I'm alive!&lt;/code>, or 503 during shutdown&lt;/td>
&lt;td>&lt;code>livenessProbe&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/health/readiness&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>State of the process and the database. &lt;strong>503 if the configured Postgres does not answer&lt;/strong>&lt;/td>
&lt;td>&lt;code>readinessProbe&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/health&lt;/code>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Fires a real request against &lt;strong>every model&lt;/strong> in the catalogue&lt;/td>
&lt;td>Manual diagnosis, never a probe&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last row deserves insisting on. &lt;code>/health&lt;/code> spends tokens on every call, one per configured model. Set as a probe, with the Kubernetes default interval and a fleet of eight models, that is thousands of inference requests a day that serve nobody.&lt;/p>
&lt;p>There are more endpoints useful for diagnosis: &lt;code>/health/readiness/details&lt;/code>, &lt;code>/health/services?service=langfuse&lt;/code> to check one specific callback, &lt;code>/health/history&lt;/code> and &lt;code>/health/latest&lt;/code> for the history, and &lt;code>/health/backlog&lt;/code>, which returns the in-flight requests and shows up in the latency section. &lt;code>/health/drain&lt;/code> exists but returns 404 unless it is enabled with &lt;code>enable_drain_endpoint: true&lt;/code>, and it is protected with &lt;code>X-Drain-Token&lt;/code>.&lt;/p>
&lt;p>The periodic model checks are configured separately and not in the probe:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">background_health_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">health_check_interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">300&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># seconds&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">background_health_check_model_groups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llama-70b&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;qwen-30b&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And one setting with consequences in deployments with no Internet egress: &lt;code>allow_requests_on_db_unavailable: true&lt;/code> allows serving traffic with the database down, but leaves &lt;code>/health/readiness&lt;/code> always returning 200. With that set, the readiness probe stops detecting the very failure it was designed to detect, and budget and model errors keep blocking just the same.&lt;/p>
&lt;h2 id="retries-cooldowns-and-fallbacks-the-real-numbers">Retries, cooldowns and fallbacks: the real numbers&lt;/h2>
&lt;p>The defaults, read from the project&amp;rsquo;s code and not from the documentation:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Constant&lt;/th>
&lt;th>Value&lt;/th>
&lt;th>What it governs&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>DEFAULT_MAX_RETRIES&lt;/code>&lt;/td>
&lt;td>2&lt;/td>
&lt;td>Retries before moving on to fallbacks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_ALLOWED_FAILS&lt;/code>&lt;/td>
&lt;td>3&lt;/td>
&lt;td>Failures before cooling down a deployment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_COOLDOWN_TIME_SECONDS&lt;/code>&lt;/td>
&lt;td>5&lt;/td>
&lt;td>Seconds a deployment stays out&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD&lt;/code>&lt;/td>
&lt;td>1000&lt;/td>
&lt;td>Minimum requests before applying the cooldown logic with a single deployment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_REQUEST_TIMEOUT_SECONDS&lt;/code>&lt;/td>
&lt;td>6000.0&lt;/td>
&lt;td>Request timeout&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Besides the failure counter, there is a proportional criterion: a deployment enters cooldown if 50% of its requests fail in any given minute.&lt;/p>
&lt;p>On the cooldown there is a discrepancy worth knowing about before debugging blind. The docstring of the router function says &lt;code>cooldown_time&lt;/code> defaults to 1, and the code uses the constant of 5. The code wins.&lt;/p>
&lt;p>The fallbacks are three separate lists and are applied &lt;strong>after&lt;/strong> the retries are exhausted:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;qwen-30b&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>}&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">context_window_fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llama-70b-128k&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>}&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">content_policy_fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llama-70b-sin-guardrail&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>}&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A note on the behaviour when everything is in cooldown: if no deployment is left available in the group, LiteLLM falls back onto a specific &lt;code>model_info.id&lt;/code>, skipping the cooldown check. It is a deliberate degradation, serve something rather than return 503, and it is worth knowing it exists because it masks the real state of the fleet.&lt;/p>
&lt;p>And a capability lost along the way: since version 1.85.0, the &lt;code>mock_testing_fallbacks&lt;/code> parameter and its two siblings are stripped from requests arriving through the proxy and have no effect. Fallbacks can no longer be tested against the proxy with a doctored request; it has to be done against &lt;code>litellm.Router&lt;/code> directly, in a test. Anyone who had a smoke test built on that has it broken without warning.&lt;/p>
&lt;h3 id="the-six-thousand-second-timeout">The six thousand second timeout&lt;/h3>
&lt;p>It deserves its own section because it is the setting with the worst damage-to-effort ratio in the whole system. &lt;code>request_timeout&lt;/code> is 6,000 seconds. A hundred minutes. An inference engine that stops answering without closing the connection holds a worker for that long, and with one worker per pod that is an entire pod out of service per trapped request.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Ten minutes is still generous for a long generation, and it bounds the damage.&lt;/p>
&lt;h2 id="overhead-the-projects-two-figures">Overhead: the project&amp;rsquo;s two figures&lt;/h2>
&lt;p>LiteLLM publishes two measurements of its own overhead that differ by a factor of thirty, and both are legitimate because they measure different loads.&lt;/p>
&lt;p>The benchmarks page, with Locust, a thousand users with think time and a fake endpoint, gives for four instances an &lt;code>x-litellm-overhead-duration-ms&lt;/code> of &lt;strong>median 2 ms, p95 8 ms and p99 13 ms&lt;/strong> at 1,170 requests per second. With two instances, median 12 ms, p95 29 ms and p99 43 ms. The page itself warns that those figures correspond to around 130 in-flight requests, and that a closed-loop client with no think time keeps a thousand in flight and sees on the order of eight times that latency at the same rate, by Little&amp;rsquo;s law.&lt;/p>
&lt;p>The AIGatewayBench benchmark, from July 2026, measures the Python proxy at &lt;strong>257.7 ms of added p99&lt;/strong> and 329.5 MB of peak memory, with no callbacks, no spend tracking and no persistence. The Rust variant, in beta, gives 0.7 ms.&lt;/p>
&lt;p>The reading for an in-house platform: the first figure is the best case with human clients, and the second is the order of magnitude to plan for agentic load, which arrives in a closed loop against fast responses. There is one reported case that resembles this scenario closely: an in-house OpenAI-compatible engine served around 16 requests per second directly and around 9 through LiteLLM, with the degradation growing with concurrency, on a 4 vCPU, 8 GB pod.&lt;/p>
&lt;h3 id="the-latency-gap-the-metrics-cannot-see">The latency gap the metrics cannot see&lt;/h3>
&lt;p>LiteLLM&amp;rsquo;s timers start when its handler starts. The time the request spends queued in the uvicorn event loop, before getting there, appears in none of its metrics. The example in its own troubleshooting guide is a case in which LiteLLM records 10 seconds and the user experiences 20.&lt;/p>
&lt;p>It is detected by comparing two sources: &lt;code>GET /health/backlog&lt;/code> or the &lt;code>litellm_in_flight_requests&lt;/code> gauge against the response time measured by the load balancer in front. If they diverge, the gap is in the input queue and the answer is more replicas, not more tuning.&lt;/p>
&lt;p>Two headers are always on and serve to watch it without instrumenting anything: &lt;code>x-litellm-overhead-duration-ms&lt;/code> and &lt;code>x-litellm-callback-duration-ms&lt;/code>. For the second, the documentation sets the suspicion threshold at 100 ms, above which the diagnosis is that the payloads are too large.&lt;/p>
&lt;p>And the first cause of latency that guide mentions is none of the above: &lt;code>LITELLM_LOG=DEBUG&lt;/code> serialises the payload with &lt;code>json.dumps(indent=4)&lt;/code> synchronously, and with payloads over 2 MB that alone can cost between 2 and 5 seconds per request. A log level set to debug a problem and forgotten there produces exactly the problem it was meant to debug.&lt;/p>
&lt;h2 id="the-failure-mode-to-put-in-the-runbook">The failure mode to put in the runbook&lt;/h2>
&lt;p>There is an open issue describing a cascade with a characteristic shape. Under sustained 429s from the engine upstream, the pods stopped answering readiness probes during startup, Kubernetes killed them on failed probes, and the restart put more pressure on an upstream that was already saturated. Peaks of 500 requests per second, average of 60, between two and five replicas with 1.3 CPU and 4 GB.&lt;/p>
&lt;p>The shape of the failure is what matters more than the specific case: &lt;strong>the engine&amp;rsquo;s saturation turns into a restart loop of the gateway&lt;/strong>, and from the outside it looks like the problem is the gateway. The defences are the ones already in this article, applied together: &lt;code>startupProbe&lt;/code> with a wide margin so readiness does not compete with startup, &lt;code>request_timeout&lt;/code> bounded so trapped requests get released, cooldowns that take the deployment returning 429 out of rotation, and CPU autoscaling at 60% so the capacity exists before it is needed. It fits with what was covered in &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">incident response runbooks&lt;/a>.&lt;/p>
&lt;h2 id="upgrading-without-cutting-traffic">Upgrading without cutting traffic&lt;/h2>
&lt;p>The deployment is a normal &lt;code>Deployment&lt;/code> with &lt;code>RollingUpdate&lt;/code>, &lt;code>maxUnavailable: 0&lt;/code> and &lt;code>maxSurge: 1&lt;/code>, and there is no further mystery to it except on one point: &lt;strong>schema migrations&lt;/strong>. LiteLLM uses Prisma and applies migrations at startup. With several replicas coming up at once after a version change, the migration must be run by a single process, which is another reason for the dedicated replica with &lt;code>LITELLM_JOB_ROLE: worker&lt;/code>.&lt;/p>
&lt;p>Before a version bump, two checks that pay for themselves: read the release notes looking for changes in the metadata emitted to the callbacks, because a change like that breaks saved dashboards without touching the service, and verify that the new configuration starts with &lt;code>--detailed_debug&lt;/code> on a separate pod before applying it to the fleet.&lt;/p>
&lt;h2 id="go-live-checklist">Go-live checklist&lt;/h2>
&lt;ol>
&lt;li>&lt;code>--num_workers 1&lt;/code> and scaling by replicas.&lt;/li>
&lt;li>&lt;code>requests&lt;/code> equal to &lt;code>limits&lt;/code>, 1 vCPU and 4 GiB per worker.&lt;/li>
&lt;li>HPA on CPU at 60%. Never on memory.&lt;/li>
&lt;li>&lt;code>database_connection_pool_limit&lt;/code> computed from &lt;code>max_connections&lt;/code>, and the chart&amp;rsquo;s &lt;code>maxReplicas&lt;/code> lowered to something the database can take.&lt;/li>
&lt;li>Redis as soon as there is more than one replica, and &lt;code>use_redis_transaction_buffer&lt;/code> from ten instances or a thousand requests per second.&lt;/li>
&lt;li>&lt;code>livenessProbe&lt;/code> on &lt;code>/health/liveliness&lt;/code>, &lt;code>readinessProbe&lt;/code> on &lt;code>/health/readiness&lt;/code>, and &lt;code>/health&lt;/code> out of the probes.&lt;/li>
&lt;li>&lt;code>startupProbe&lt;/code> with a margin of up to 300 seconds.&lt;/li>
&lt;li>&lt;code>request_timeout: 600&lt;/code>.&lt;/li>
&lt;li>&lt;code>LITELLM_LOG&lt;/code> out of &lt;code>DEBUG&lt;/code>, and &lt;code>disable_error_logs: true&lt;/code>.&lt;/li>
&lt;li>A dashboard with &lt;code>x-litellm-overhead-duration-ms&lt;/code>, &lt;code>x-litellm-callback-duration-ms&lt;/code>, &lt;code>litellm_in_flight_requests&lt;/code> and the response time of the load balancer in front.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>The HPA scales up and never back down.&lt;/strong> It is measuring memory, which in this process only goes up.&lt;/p>
&lt;p>&lt;strong>The pods die in a loop right after deploying.&lt;/strong> Less than 4 GiB of limit. The footprint appears with the first large request, not at startup.&lt;/p>
&lt;p>&lt;strong>Postgres starts rejecting connections at the peak.&lt;/strong> The chart has &lt;code>maxReplicas: 100&lt;/code> and the pool defaults to 10.&lt;/p>
&lt;p>&lt;strong>The per-minute limits are enforced badly.&lt;/strong> There is no Redis, and each replica keeps its own count.&lt;/p>
&lt;p>&lt;strong>The health check consumes tokens.&lt;/strong> Someone set &lt;code>/health&lt;/code> as the readiness probe.&lt;/p>
&lt;p>&lt;strong>The readiness probe returns 200 with the database down.&lt;/strong> &lt;code>allow_requests_on_db_unavailable&lt;/code> is set.&lt;/p>
&lt;p>&lt;strong>A deployment keeps receiving traffic after failing.&lt;/strong> The cooldown is 5 seconds, not minutes, and with a single deployment in the group you have to pass a thousand requests for the logic to apply.&lt;/p>
&lt;p>&lt;strong>The fallback test no longer tests anything.&lt;/strong> The simulation parameters are stripped from requests arriving through the proxy since 1.85.0.&lt;/p>
&lt;p>&lt;strong>The p99 is ten times worse than the published benchmark.&lt;/strong> The benchmark has think time and the real load is agentic.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>None of these settings is complicated and all of them are one line. What unites them is that their defaults describe a small deployment, with one instance, a human client on the other side and a commercial provider taking care of capacity. An in-house inference factory is the opposite on all four axes: several replicas, automated clients, and an engine whose capacity is finite and known.&lt;/p>
&lt;p>Of the ten points in the checklist, three decide almost everything in an on-premise deployment: the process model with its sizing, the database connection arithmetic, and the request timeout. The other seven are the ones that avoid the Saturday phone call.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the other half of day 2: cost per token on in-house models, trace correlation and the telemetry queues this post takes as given.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router: the L7 switchboard&lt;/a> — the router&amp;rsquo;s four functions and the routing strategies whose latency is discussed here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the switchboard: which OSS gateway to put in front&lt;/a> — the prior decision, with verified licences.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks&lt;/a> — where the cascade of 429s and failed probes described here fits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the complete journey, of which this post details the gateway leg.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/">GuideLLM and SLO validation under load&lt;/a> — how to measure the real saturation point before setting the limits in this article.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — where the gateway lives in the complete building.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Production best practices&lt;/em> (topología, workers, memoria, pool de conexiones, &lt;code>LITELLM_JOB_ROLE&lt;/code>, buffer de transacciones en Redis, &lt;code>request_timeout&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/prod">https://docs.litellm.ai/docs/proxy/prod&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Health checks&lt;/em> (los tres endpoints, comprobaciones de fondo, &lt;code>enable_drain_endpoint&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/health">https://docs.litellm.ai/docs/proxy/health&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Reliability&lt;/em> (fallbacks, reintentos, deprecación de los parámetros de simulación en la 1.85.0): &lt;a href="https://docs.litellm.ai/docs/proxy/reliability">https://docs.litellm.ai/docs/proxy/reliability&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Benchmarks&lt;/em> y &lt;em>Load test advanced&lt;/em> (las cifras de sobrecarga por número de instancias y la advertencia de la ley de Little): &lt;a href="https://docs.litellm.ai/docs/benchmarks">https://docs.litellm.ai/docs/benchmarks&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/load_test_advanced">https://docs.litellm.ai/docs/load_test_advanced&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Rust AI Gateway benchmarks&lt;/em> (los 257,7 ms de p99 del proxy en Python): &lt;a href="https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks">https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Latency overhead&lt;/em> (el hueco de la cola de uvicorn, las cabeceras de diagnóstico, el coste de &lt;code>LITELLM_LOG=DEBUG&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/troubleshoot/latency_overhead">https://docs.litellm.ai/docs/troubleshoot/latency_overhead&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>DB info&lt;/em> (&lt;code>disable_spend_logs&lt;/code>, &lt;code>disable_error_logs&lt;/code> y qué se pierde con cada uno): &lt;a href="https://docs.litellm.ai/docs/proxy/db_info">https://docs.litellm.ai/docs/proxy/db_info&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/constants.py&lt;/code> (los valores por defecto de reintentos, fallos permitidos, enfriamiento y timeout): &lt;a href="https://github.com/BerriAI/litellm/blob/main/litellm/constants.py">https://github.com/BerriAI/litellm/blob/main/litellm/constants.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/router.py&lt;/code> (la discrepancia entre el docstring de &lt;code>cooldown_time&lt;/code> y la constante que usa el código): &lt;a href="https://github.com/BerriAI/litellm/blob/main/litellm/router.py">https://github.com/BerriAI/litellm/blob/main/litellm/router.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, issue #15526 — cascada de 429 sostenidos, sondas de readiness fallidas y bucle de reinicios: &lt;a href="https://github.com/BerriAI/litellm/issues/15526">https://github.com/BerriAI/litellm/issues/15526&lt;/a>.&lt;/li>
&lt;li>LiteLLM, issue #21046 — degradación de throughput contra un motor propio compatible con OpenAI: &lt;a href="https://github.com/BerriAI/litellm/issues/21046">https://github.com/BerriAI/litellm/issues/21046&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>LiteLLM and Langfuse: the operational pair, and the four places where the trace is lost between the gateway and the dashboard</title><link>https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/</link><pubDate>Mon, 07 Sep 2026 18:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/</guid><description>&lt;blockquote>
&lt;p>This post opens the operational track of the control layer. The previous articles left the pieces chosen and assembled: &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">choosing the OSS gateway&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 inference router&lt;/a> explain why LiteLLM ends up in front of the fleet, and &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a> takes apart the backend that receives the traces. Here we deal with the seam between the two, which is where the work of the next three years lives.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The LiteLLM plus Langfuse pair is configured with a list of callbacks and two API keys, and from there four matters decide whether the dashboard is good for operating or only for showing in a demo.&lt;/p>
&lt;p>&lt;strong>There are three integration routes and they are not equivalent.&lt;/strong> The native &lt;code>langfuse&lt;/code> callback is tied to the Python SDK v2 (&lt;code>langfuse&amp;gt;=2.59.7,&amp;lt;3.0&lt;/code> in LiteLLM&amp;rsquo;s &lt;code>pyproject.toml&lt;/code>), which writes against the legacy ingestion that Langfuse Cloud retires on &lt;strong>16 November 2026&lt;/strong>. The &lt;code>langfuse_otel&lt;/code> preset exports over OTLP and is the path recommended in the current documentation of both projects. The OTel v2 route, behind the &lt;code>LITELLM_OTEL_V2=true&lt;/code> flag, changes span names, leaves the success status at &lt;code>UNSET&lt;/code> and, above all, its Langfuse mapper does not read the request metadata: &lt;code>session_id&lt;/code>, &lt;code>trace_user_id&lt;/code>, &lt;code>tags&lt;/code> and &lt;code>trace_id&lt;/code> stop arriving.&lt;/p>
&lt;p>&lt;strong>The cost of a self-hosted model comes out as zero unless a price is registered.&lt;/strong> &lt;code>get_model_info()&lt;/code> raises an exception for a model that is not in the pricing map, &lt;code>completion_cost()&lt;/code> propagates it, the logger catches it, stores it in a debugging field and returns &lt;code>None&lt;/code>, and the payload turns it into &lt;code>0.0&lt;/code>. There is no warning at the default log level. A team&amp;rsquo;s budget is never exhausted because its spend is always zero.&lt;/p>
&lt;p>&lt;strong>Client-gateway-engine correlation does not come out of the box.&lt;/strong> LiteLLM does continue the client&amp;rsquo;s trace if a &lt;code>traceparent&lt;/code> arrives, and it also extracts from it the 32-hex trace-id to use as &lt;code>litellm_trace_id&lt;/code>, which makes the Langfuse identifier equal to the W3C one. Upstream the story changes: &lt;code>forward_traceparent_to_llm_provider&lt;/code> is &lt;code>False&lt;/code> by default, the function that applies it returns early if no &lt;code>otel&lt;/code> logger is loaded, and when it does act it forwards the client&amp;rsquo;s original header, so the vLLM span ends up a sibling of LiteLLM&amp;rsquo;s rather than a child. Add to that the fact that vLLM exports OTLP over gRPC by default and Langfuse does not accept gRPC.&lt;/p>
&lt;p>&lt;strong>Between the request and the dashboard there are four bounded queues that drop events.&lt;/strong> LiteLLM&amp;rsquo;s logging thread pool (100 threads, 10,000 pending tasks, dropping once full), the asynchronous &lt;code>LoggingWorker&lt;/code> (50,000, and once full it aggressively flushes half), OTel&amp;rsquo;s &lt;code>BatchSpanProcessor&lt;/code> (2,048 spans by default) and Langfuse&amp;rsquo;s &lt;code>ClickhouseWriter&lt;/code>, which drops rows after exhausting the retries with no dead-letter queue. None of the four applies back pressure on the request, which is exactly what you want, and none of them guarantees delivery, which is exactly what an auditor asks about.&lt;/p>
&lt;p>The rest of the article develops the four points with configuration applicable to a generic 4×H100 cluster with vLLM, the overhead figures the project itself publishes, and the honest limit of what this data sustains before ENS or ISO/IEC 42001.&lt;/p>
&lt;h2 id="you-are-here-the-seam-between-deploy-and-observe">You are here: the seam between DEPLOY and OBSERVE&lt;/h2>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a>, LiteLLM lives in the gateway layer and Langfuse in the observability one. The separation is clean on the diagram and false in operation: the gateway is the only point in the system that sees the complete request before chopping it up and the complete response after reassembling it, so it is also the only reasonable place from which to emit the trace. The inference engine sees tokens and latencies; it does not see who is asking or how much that team has spent this month.&lt;/p>
&lt;p>That turns LiteLLM into a telemetry producer as well as a traffic distributor, and moves onto the gateway a responsibility that was not in the original design: if the callback fails, the request is served all the same and nobody notices that the dashboard has been incomplete for two days.&lt;/p>
&lt;h2 id="the-analogy-the-toll-booth-that-issues-the-ticket">The analogy: the toll booth that issues the ticket&lt;/h2>
&lt;p>A motorway toll booth does two independent jobs. It raises the barrier, which is its visible function, and it issues a ticket with number plate, time, section and amount, which is the function the concession lives off. The two share a booth and do not share a destination: the barrier responds in a hundred milliseconds because there is a car waiting, and the ticket travels to a processing centre over a slower channel that batches thousands of records before sending them.&lt;/p>
&lt;p>The design is well thought out as long as its consequence is understood. If the processing centre goes down, cars keep going through. If the ticket queue fills up, tickets are thrown away, traffic is not stopped. The concession accepts losing records rather than causing a jam, and charges on a statistical sample it knows to be imperfect.&lt;/p>
&lt;p>LiteLLM and Langfuse work the same way. The four queues in the corresponding section are the slow channel, and all of them prefer to drop rather than block. Whoever builds the pair inheriting the complete analogy gets the operation right. Whoever thinks the ticket is an accounting entry ends up explaining to an auditor why traces from a Tuesday afternoon are missing.&lt;/p>
&lt;h2 id="three-routes-and-one-with-an-expiry-date">Three routes, and one with an expiry date&lt;/h2>
&lt;p>As of September 2026 three paths coexist for getting a request from LiteLLM to Langfuse. LiteLLM&amp;rsquo;s documentation already labels the first as legacy.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Route&lt;/th>
&lt;th>How it is enabled&lt;/th>
&lt;th>Transport&lt;/th>
&lt;th>Status&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Native &lt;code>langfuse&lt;/code> callback&lt;/td>
&lt;td>&lt;code>success_callback: [&amp;quot;langfuse&amp;quot;]&lt;/code>&lt;/td>
&lt;td>Langfuse Python SDK v2, legacy ingestion&lt;/td>
&lt;td>Maintained for compatibility. Tied to &lt;code>langfuse&amp;lt;3.0&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>langfuse_otel&lt;/code> preset (v1)&lt;/td>
&lt;td>&lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code>&lt;/td>
&lt;td>OTLP/HTTP against &lt;code>/api/public/otel&lt;/code>&lt;/td>
&lt;td>Recommended by both projects&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel v2 route&lt;/td>
&lt;td>&lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code> + &lt;code>LITELLM_OTEL_V2=true&lt;/code>&lt;/td>
&lt;td>OTLP/HTTP, new mapper&lt;/td>
&lt;td>Opt-in. Changes semantics and loses metadata&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The date that orders the decision is &lt;strong>16 November 2026&lt;/strong>. Langfuse&amp;rsquo;s compatibility matrix marks the legacy batch ingestion of the Python SDK v2 as retired on that date for Cloud, at which point trace ingestion through that route stops working. On self-hosted the endpoint survives, but a v4 server in &lt;code>events_only&lt;/code> mode returns 400 for every event type except &lt;code>score-create&lt;/code> and &lt;code>sdk-log&lt;/code>.&lt;/p>
&lt;p>Since LiteLLM&amp;rsquo;s native callback is pinned to &lt;code>langfuse&amp;gt;=2.59.7,&amp;lt;3.0&lt;/code> in its own &lt;code>pyproject.toml&lt;/code>, and its code calls APIs that only exist in SDK v2 (&lt;code>self.Langfuse.trace(...)&lt;/code>, &lt;code>trace.generation(...)&lt;/code>), there is no way to move up an SDK without changing route. Anyone who today has &lt;code>success_callback: [&amp;quot;langfuse&amp;quot;]&lt;/code> against Langfuse Cloud has a migration with a date on it. Anyone who has it against a self-hosted Langfuse has one with some slack, because v3 receives security patches until the end of January 2027, but they have one all the same.&lt;/p>
&lt;p>One detail of the OTLP route worth checking in older deployments: the &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header is the one that enables real-time ingestion. Without it the traces arrive, but with a delay that Langfuse&amp;rsquo;s documentation puts at around ten minutes (the compatibility page says fifteen, and that discrepancy remains unresolved in their own documents). LiteLLM started sending it in version 1.95.0, of August 2026. A proxy older than that version against a Langfuse v4 produces the classic symptom: the dashboard works, but it is always behind and nobody knows why.&lt;/p>
&lt;h2 id="the-configuration-that-works-today">The configuration that works today&lt;/h2>
&lt;p>For a generic 4×H100 cluster with vLLM behind it and Langfuse self-hosted in the same Kubernetes, the OTLP route in its v1 variant is the one that gives the most functionality with the fewest surprises.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/meta-llama/Llama-3.3-70B-Instruct&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-llama70b.inference.svc.cluster.local:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/VLLM_API_KEY&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Price derived from the cluster&amp;#39;s €/GPU-hour. Without this, spend = 0.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000031&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000089&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">turn_off_message_logging&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># a conscious decision, see the PII section&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">langfuse_default_tags&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;model_group&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;user_api_key_team_alias&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cache_hit&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">disable_error_logs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># provider errors inflate SpendLogs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The credentials travel through the environment. &lt;code>LANGFUSE_PUBLIC_KEY&lt;/code>, &lt;code>LANGFUSE_SECRET_KEY&lt;/code> and &lt;code>LANGFUSE_HOST&lt;/code> are enough: the preset builds the &lt;code>Authorization: Basic base64(public:secret)&lt;/code> header and adds the ingestion version one, so there is no need to touch &lt;code>OTEL_EXPORTER_OTLP_HEADERS&lt;/code> by hand. The preset&amp;rsquo;s default host points to the US region of Langfuse Cloud, a relevant detail for anyone building a platform with data sovereignty requirements: if the variable is not defined, the telemetry leaves Spain without anyone having decided it.&lt;/p>
&lt;p>For real multi-tenancy, LiteLLM supports per-team Langfuse credentials (&lt;code>POST /team/&amp;lt;id&amp;gt;/callback&lt;/code> with &lt;code>callback_vars&lt;/code>) and per-key ones (&lt;code>metadata.logging[]&lt;/code> when generating the key), and the key&amp;rsquo;s wins over the team&amp;rsquo;s without mixing. There is a limitation that breaks the architecture of anyone who planned to separate tenants across different Langfuse instances: under OTel v2, a per-key or per-team &lt;code>langfuse_host&lt;/code> does not move that tenant to another host, because the exporter fixes the destination at startup and the only thing that varies per tenant are the headers.&lt;/p>
&lt;p>And one operational warning with years of history behind it: &lt;code>MAX_LANGFUSE_INITIALIZED_CLIENTS&lt;/code> is 50 by default because each Langfuse client starts a thread. The comment in LiteLLM&amp;rsquo;s code is explicit about the incident that motivated it, with the CPU at 100% from repeated initialisations. A deployment with per-team credentials and many teams hits that ceiling.&lt;/p>
&lt;h2 id="what-is-lost-when-moving-to-otel-v2">What is lost when moving to OTel v2&lt;/h2>
&lt;p>The &lt;code>LITELLM_OTEL_V2=true&lt;/code> flag brings a step up in quality in the proxy&amp;rsquo;s general instrumentation, and at the same time a concrete regression on the path to Langfuse that the documentation acknowledges without underlining it.&lt;/p>
&lt;p>The Langfuse mapper of the v2 route emits &lt;code>langfuse.observation.type&lt;/code>, &lt;code>langfuse.observation.model.name&lt;/code>, the observation identifier, the model parameters, input and output, &lt;code>usage_details&lt;/code> and &lt;code>cost_details&lt;/code>, plus &lt;code>langfuse.trace.metadata.team_id&lt;/code> and &lt;code>team_alias&lt;/code>. It does not emit &lt;code>session.id&lt;/code>, nor &lt;code>user.id&lt;/code>, nor &lt;code>langfuse.trace.tags&lt;/code>, nor &lt;code>langfuse.trace.id&lt;/code>, nor the generation name, nor &lt;code>parent_observation_id&lt;/code>. LiteLLM&amp;rsquo;s documents put it this way: those attributes are set by the preset from the request and the response, not from metadata supplied by the client.&lt;/p>
&lt;p>The practical consequence is that an application that today groups conversations by sending &lt;code>metadata: {&amp;quot;session_id&amp;quot;: &amp;quot;...&amp;quot;, &amp;quot;trace_user_id&amp;quot;: &amp;quot;...&amp;quot;}&lt;/code> in the request body stops grouping them when the flag is enabled, with no error and no warning. The traces keep arriving, loose.&lt;/p>
&lt;p>The other v2 changes are semantic and affect alerts already written. The root span goes from being called &lt;code>Received Proxy Server Request&lt;/code> to being named after the route (&lt;code>POST /v1/chat/completions&lt;/code>), the inference span goes from &lt;code>litellm_request&lt;/code> to &lt;code>{operation} {model}&lt;/code>, the guardrail spans stop hanging off the inference span and hang off the root instead, and the success status stays at &lt;code>UNSET&lt;/code> instead of &lt;code>OK&lt;/code>. Any rule that fires on &lt;code>status == OK&lt;/code> stops firing. The way back is to remove the flag and restart.&lt;/p>
&lt;h2 id="cost-why-the-trace-says-zero">Cost: why the trace says zero&lt;/h2>
&lt;p>This is the part that takes the most time to diagnose and the least to fix.&lt;/p>
&lt;p>LiteLLM computes the cost of a request from a per-model pricing map. For commercial provider models the map comes filled in. For &lt;code>hosted_vllm/whatever&lt;/code>, which is all there is in an in-house inference factory, the map is empty, and the failure path is as follows:&lt;/p>
&lt;ol>
&lt;li>&lt;code>litellm.get_model_info()&lt;/code> raises an exception with the message &lt;code>This model isn't mapped yet&lt;/code>.&lt;/li>
&lt;li>&lt;code>completion_cost()&lt;/code> and &lt;code>response_cost_calculator()&lt;/code> propagate the exception.&lt;/li>
&lt;li>&lt;code>Logging._response_cost_calculator()&lt;/code> catches it, writes a &lt;code>StandardLoggingModelCostFailureDebugInformation&lt;/code> into &lt;code>response_cost_failure_debug_information&lt;/code>, logs it at &lt;strong>debug&lt;/strong> level and returns &lt;code>None&lt;/code>.&lt;/li>
&lt;li>The standard logging payload does &lt;code>llm_response_cost = raw_response_cost or 0.0&lt;/code>.&lt;/li>
&lt;/ol>
&lt;p>The system delivers a spend of zero, with no visible error at the default log level, and writes it into the &lt;code>spend&lt;/code> column of &lt;code>LiteLLM_SpendLogs&lt;/code>. The per-key, per-team and per-organisation budgets are evaluated against that zero and are never exhausted. The Langfuse dashboard shows a total cost of zero for everything self-hosted, which is precisely the part you wanted to measure.&lt;/p>
&lt;h3 id="registering-the-price">Registering the price&lt;/h3>
&lt;p>The pricing keys are accepted both in &lt;code>litellm_params&lt;/code> and in &lt;code>model_info&lt;/code>, and those in &lt;code>litellm_params&lt;/code> win when they are in both places. The complete set has 91 fields, but for a deployment with vLLM two are enough, and there are three things to know about them.&lt;/p>
&lt;p>The first is that &lt;strong>the price is per individual token, in USD&lt;/strong>. There are no per-thousand variants: searching for &lt;code>per_1k&lt;/code> or &lt;code>per_1000&lt;/code> in the pricing model returns nothing. A three-orders-of-magnitude error here is easy to make and hard to see, because it produces plausible figures.&lt;/p>
&lt;p>The second is that &lt;code>input_cost_per_request&lt;/code> exists in the schema and &lt;strong>is not applied&lt;/strong> in chat calls: a model registered with that field returns a cost of 0.0. To charge per request it has to be converted into a cost per token.&lt;/p>
&lt;p>The third is that &lt;code>input_cost_per_second&lt;/code> needs the response time and is computed as price times &lt;code>response_time_ms / 1000&lt;/code>, so outside the proxy&amp;rsquo;s request path it silently returns zero.&lt;/p>
&lt;p>The number that goes into those two keys comes from the article on &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy with LiteLLM&lt;/a> and the one on &lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">cost per token and per request&lt;/a>: amortisation plus energy divided by tokens served. The example arithmetic, to set the order of magnitude: a cluster whose fully loaded cost is 9.60 USD per hour sustaining 3,000 output tokens per second produces 10.8 million tokens an hour, that is 0.00000089 USD per output token. That is the value that goes into &lt;code>output_cost_per_token&lt;/code>. If the cluster&amp;rsquo;s average utilisation is 30 % and not 100 %, the divisor changes and the price triples, which is the same lever that article develops.&lt;/p>
&lt;p>Before sending traffic, the &lt;code>/spend/calculate&lt;/code> endpoint accepts the same parameters as &lt;code>completion_cost&lt;/code> and returns the computed cost. It is the cheap way to check that the price resolves.&lt;/p>
&lt;h3 id="how-that-number-reaches-langfuse-and-who-wins">How that number reaches Langfuse, and who wins&lt;/h3>
&lt;p>The two integration routes write the cost into different fields. The native callback sends it as &lt;code>usage.total_cost&lt;/code> of the v2 schema, and duplicates it in the trace metadata as &lt;code>litellm_response_cost&lt;/code>. The OTLP preset emits &lt;code>langfuse.observation.cost_details&lt;/code> as a JSON string of the form &lt;code>{&amp;quot;total&amp;quot;: cost}&lt;/code>, and &lt;code>langfuse.observation.usage_details&lt;/code> with input, output and total.&lt;/p>
&lt;p>The rule on the Langfuse side is documented and is the right one for this case: when there is ingested cost and inferred cost, &lt;strong>the ingested one takes priority&lt;/strong>. Langfuse only computes from its own pricing table when the emitter sends no cost, and changes to a model definition apply to new generations, with no retroactive recalculation. A self-hosted model therefore needs no pricing definition in Langfuse: it is enough for LiteLLM to send &lt;code>cost_details&lt;/code>.&lt;/p>
&lt;p>One detail remains that explains a puzzling symptom. Langfuse stores the ingested cost and the computed one in separate columns, &lt;code>totalCost&lt;/code> and &lt;code>calculatedTotalCost&lt;/code>, and the session header totals add up only the first. A mixed deployment where part of the traffic brings cost and part lets Langfuse compute it shows sessions at zero while the individual generations show cost. There is an open issue about it.&lt;/p>
&lt;h3 id="the-tokens-of-a-streaming-response">The tokens of a streaming response&lt;/h3>
&lt;p>Almost all of an assistant&amp;rsquo;s traffic is streamed, and there the OpenAI &lt;code>usage&lt;/code> object only appears if the client asks for &lt;code>stream_options.include_usage&lt;/code>. LiteLLM solves this by injecting the option on its own: on the asynchronous completion routes with &lt;code>stream: true&lt;/code>, if the caller did not set it and all the candidate deployments support it, it adds it and marks &lt;code>_litellm_strip_stream_usage&lt;/code> so the usage block is used in the cost calculation and not returned to the client. The &lt;code>general_settings.always_include_stream_usage&lt;/code> flag forces or disables that behaviour, and is worth setting to &lt;code>True&lt;/code> when capability detection does not recognise the vLLM deployment.&lt;/p>
&lt;p>When the usage is missing anyway, LiteLLM reassembles the response with &lt;code>stream_chunk_builder()&lt;/code> and counts tokens with the tokeniser. It is an approximation, and it produces the second path towards a cost of zero. The two paths to zero are told apart by looking at &lt;code>total_tokens&lt;/code> in &lt;code>LiteLLM_SpendLogs&lt;/code>: zero tokens with a correct price is a usage problem, correct tokens with zero cost is a price problem.&lt;/p>
&lt;h2 id="correlation-from-the-client-to-the-engine">Correlation: from the client to the engine&lt;/h2>
&lt;p>The operational question that justifies the whole setup is being able to take a user complaint, find their request and see where the time went. That requires the trace identifier to survive three hops.&lt;/p>
&lt;div class="diagram" style="max-width:860px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 860 330" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Propagation of the client's traceparent to the LiteLLM gateway and the vLLM engine, with the points where correlation breaks">
&lt;style>
.bx{fill:none;stroke:currentColor;stroke-width:1.3}
.bxd{fill:none;stroke:currentColor;stroke-width:1;stroke-dasharray:4 3}
.t{font:600 13px sans-serif;fill:currentColor}
.h{font:600 12px sans-serif;fill:currentColor}
.s{font:11px sans-serif;fill:currentColor}
.ar{stroke:currentColor;stroke-width:1.2;fill:none;marker-end:url(#ah)}
.ard{stroke:currentColor;stroke-width:1.1;fill:none;stroke-dasharray:5 3;marker-end:url(#ah)}
&lt;/style>
&lt;defs>&lt;marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="430" y="18" text-anchor="middle" class="t">One identifier, three hops and two places where it is lost&lt;/text>
&lt;rect x="20" y="40" width="150" height="60" class="bx" rx="6"/>
&lt;text x="36" y="62" class="h">Client&lt;/text>
&lt;text x="36" y="78" class="s">traceparent: 00-abc…-01&lt;/text>
&lt;text x="36" y="92" class="s">baggage: session.id=…&lt;/text>
&lt;rect x="240" y="40" width="180" height="60" class="bx" rx="6"/>
&lt;text x="256" y="62" class="h">LiteLLM Proxy&lt;/text>
&lt;text x="256" y="78" class="s">litellm_trace_id = abc…&lt;/text>
&lt;text x="256" y="92" class="s">gateway parent span&lt;/text>
&lt;rect x="500" y="40" width="160" height="60" class="bx" rx="6"/>
&lt;text x="516" y="62" class="h">vLLM&lt;/text>
&lt;text x="516" y="78" class="s">gen_ai.latency.*&lt;/text>
&lt;text x="516" y="92" class="s">OTLP gRPC by default&lt;/text>
&lt;rect x="700" y="40" width="140" height="60" class="bxd" rx="6"/>
&lt;text x="716" y="62" class="h">Collector&lt;/text>
&lt;text x="716" y="78" class="s">gRPC to HTTP&lt;/text>
&lt;text x="716" y="92" class="s">renames attributes&lt;/text>
&lt;path d="M170 70 L236 70" class="ar"/>
&lt;text x="203" y="62" text-anchor="middle" class="s">1&lt;/text>
&lt;path d="M420 70 L496 70" class="ard"/>
&lt;text x="458" y="62" text-anchor="middle" class="s">2&lt;/text>
&lt;path d="M660 70 L696 70" class="ar"/>
&lt;rect x="20" y="140" width="400" height="52" class="bxd" rx="6"/>
&lt;text x="36" y="160" class="h">1 · Inbound: works&lt;/text>
&lt;text x="36" y="177" class="s">LiteLLM extracts the 32-hex trace-id from the traceparent and uses it&lt;/text>
&lt;rect x="440" y="140" width="400" height="52" class="bxd" rx="6"/>
&lt;text x="456" y="160" class="h">2 · Outbound: off by default&lt;/text>
&lt;text x="456" y="177" class="s">forward_traceparent_to_llm_provider = False&lt;/text>
&lt;rect x="20" y="212" width="820" height="96" class="bx" rx="6"/>
&lt;text x="36" y="232" class="h">The three ways hop 2 fails to happen&lt;/text>
&lt;text x="36" y="252" class="s">a · The function returns early if no otel callback is loaded, even with the flag at True&lt;/text>
&lt;text x="36" y="270" class="s">b · It forwards the client header, not its own span context: vLLM ends up a sibling, not a child&lt;/text>
&lt;text x="36" y="288" class="s">c · If the client sends no traceparent, none is created&lt;/text>
&lt;path d="M220 100 L220 136" class="ar"/>
&lt;path d="M640 100 L640 136" class="ar"/>
&lt;/svg>
&lt;/div>
&lt;h3 id="what-does-work">What does work&lt;/h3>
&lt;p>If the client sends &lt;code>traceparent&lt;/code>, LiteLLM&amp;rsquo;s spans hang off the existing trace. In addition, and independently of the OTel subsystem, &lt;code>litellm_pre_call_utils.py&lt;/code> extracts by regular expression the 32-character hexadecimal trace-id and assigns it to &lt;code>data[&amp;quot;litellm_trace_id&amp;quot;]&lt;/code> as a last resort, and takes the &lt;code>session_id&lt;/code> from the W3C &lt;code>baggage&lt;/code> header. That makes the Langfuse trace identifier equal to the W3C trace identifier, which is the most robust correlation key available: with it, an identifier copied from the application log opens the trace in Langfuse with no translation.&lt;/p>
&lt;p>For the routes that are not OTel v2, moreover, headers prefixed with &lt;code>langfuse_&lt;/code> inject per-request metadata. &lt;code>langfuse_trace_id&lt;/code>, &lt;code>langfuse_trace_user_id&lt;/code> and &lt;code>langfuse_trace_metadata&lt;/code> are documented, but the code strips the prefix and writes the rest into the metadata, so any body key works as a header. Two warnings about this: header values arrive as strings and are not interpreted as JSON, and Nginx drops headers with underscores by default (&lt;code>underscores_in_headers off&lt;/code>), which produces the symptom that the metadata works locally and disappears behind the ingress.&lt;/p>
&lt;h3 id="what-does-not-work-without-effort">What does not work without effort&lt;/h3>
&lt;p>Towards the engine, propagation is off. The flag is &lt;code>litellm.forward_traceparent_to_llm_provider&lt;/code>, it is &lt;code>False&lt;/code>, and its implementation has three behaviours to know about before enabling it:&lt;/p>
&lt;ul>
&lt;li>The function returns immediately if &lt;code>open_telemetry_logger is None&lt;/code>. A deployment with &lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code> and without the generic &lt;code>otel&lt;/code> callback propagates nothing, whatever the flag is set to.&lt;/li>
&lt;li>When it does propagate, it copies the client&amp;rsquo;s &lt;code>traceparent&lt;/code> header as is, instead of injecting the context of LiteLLM&amp;rsquo;s active span. The vLLM span ends up hanging off the client&amp;rsquo;s span-id, a sibling of the gateway call&amp;rsquo;s span. The trace is one, the hierarchy is wrong.&lt;/li>
&lt;li>If the client sends no &lt;code>traceparent&lt;/code>, none is created.&lt;/li>
&lt;/ul>
&lt;p>Add to that the fact that LiteLLM&amp;rsquo;s general policy is not to forward client headers to the provider, and that the &lt;code>forward_client_headers_to_llm_api&lt;/code> allowlist covers headers prefixed with &lt;code>x-&lt;/code>, among which &lt;code>traceparent&lt;/code> is not. This is the only route.&lt;/p>
&lt;h3 id="the-vllm-side-and-why-the-collector-stops-being-optional">The vLLM side, and why the Collector stops being optional&lt;/h3>
&lt;p>vLLM enables traces with &lt;code>--otlp-traces-endpoint&lt;/code>, and &lt;code>--collect-detailed-traces&lt;/code> with &lt;code>model&lt;/code>, &lt;code>worker&lt;/code> or &lt;code>all&lt;/code> adds forward and execute times, with a performance impact its own documentation warns about. The attributes it emits are in &lt;code>vllm/tracing/utils.py&lt;/code> and are the most useful part of the setup: &lt;code>gen_ai.latency.time_in_queue&lt;/code>, &lt;code>time_to_first_token&lt;/code>, &lt;code>time_in_scheduler&lt;/code>, &lt;code>time_in_model_prefill&lt;/code>, &lt;code>time_in_model_decode&lt;/code>, &lt;code>time_in_model_forward&lt;/code>, &lt;code>time_in_model_execute&lt;/code>, &lt;code>time_in_model_inference&lt;/code> and &lt;code>e2e&lt;/code>, plus the token counters. That breakdown is what turns a &amp;ldquo;it&amp;rsquo;s slow&amp;rdquo; into &amp;ldquo;the time goes in the queue, not in decode&amp;rdquo;, which is the conversation you want to have during an incident. It fits with what was already covered in &lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">instrumenting vLLM with OTel&lt;/a> and with the metrics in &lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">DCGM and vLLM&lt;/a>.&lt;/p>
&lt;p>There are three constraints that decide the architecture:&lt;/p>
&lt;p>vLLM exports over gRPC unless &lt;code>OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf&lt;/code> is set, and its gRPC exporter is built with &lt;code>insecure=True&lt;/code> hard-coded. Langfuse, for its part, does not support gRPC. And vLLM emits the old convention names (&lt;code>gen_ai.usage.prompt_tokens&lt;/code>, &lt;code>gen_ai.usage.completion_tokens&lt;/code>), renamed in the specification to &lt;code>input_tokens&lt;/code> and &lt;code>output_tokens&lt;/code>, plus a &lt;code>gen_ai.latency.*&lt;/code> family that was never standard and that the file itself documents as its own attributes until they are normalised.&lt;/p>
&lt;p>An OpenTelemetry Collector in between solves all three at once: it receives gRPC, exports HTTP with the Basic header towards &lt;code>/api/public/otel/v1/traces&lt;/code>, and renames attributes with a &lt;code>transform&lt;/code>. It is also the only place where tail sampling can be done, which is the next point. And add the warning from the llm-d community, which holds for any deployment with Envoy or sidecars in between: &lt;code>OTEL_SERVICE_NAME&lt;/code> has to be set per engine, because vLLM sends no service name and without it every span arrives as &lt;code>unknown_service&lt;/code>.&lt;/p>
&lt;h2 id="the-four-serial-queues">The four serial queues&lt;/h2>
&lt;p>A request&amp;rsquo;s telemetry crosses four bounded buffers before it is queryable. All four drop once full and none blocks the request.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Queue&lt;/th>
&lt;th>Capacity&lt;/th>
&lt;th>What it does once full&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LiteLLM logging thread pool&lt;/td>
&lt;td>100 threads, 10,000 pending tasks&lt;/td>
&lt;td>Drops the new ones with a rate-limited warning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Asynchronous &lt;code>LoggingWorker&lt;/code>&lt;/td>
&lt;td>50,000 events, concurrency 100&lt;/td>
&lt;td>Aggressively flushes 50 % of the queue; &lt;code>enqueue()&lt;/code> documents that it never blocks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel&amp;rsquo;s &lt;code>BatchSpanProcessor&lt;/code>&lt;/td>
&lt;td>2,048 spans (&lt;code>OTEL_BSP_MAX_QUEUE_SIZE&lt;/code>), batch 512, sent every 5 s&lt;/td>
&lt;td>Drops spans silently&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&amp;rsquo;s &lt;code>ClickhouseWriter&lt;/code>&lt;/td>
&lt;td>Bounded retries&lt;/td>
&lt;td>Drops rows after exhausting retries, with no dead-letter queue&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The first is documented in LiteLLM&amp;rsquo;s own code with a sentence that saves half an hour of discussion: logging is best-effort and, once the cap is reached, new entries are dropped with a rate-limited warning instead of being queued forever. The third is a barely visible consequence: LiteLLM builds the &lt;code>BatchSpanProcessor&lt;/code> without adjusting &lt;code>max_queue_size&lt;/code>, so the OTel SDK defaults govern, and no LiteLLM documentation mentions the &lt;code>OTEL_BSP_*&lt;/code> variables that change them. The Langfuse SDK does the same on its own account: it sets batch size and interval, and leaves the queue size at the default.&lt;/p>
&lt;p>On top of this comes a fifth loss point that is not a queue: LiteLLM does not call &lt;code>flush()&lt;/code> per request on the native route, so a &lt;code>SIGKILL&lt;/code> on the pod takes the last batch with it. On Kubernetes, this is an argument for a generous &lt;code>terminationGracePeriodSeconds&lt;/code> and a &lt;code>preStop&lt;/code> that gives some slack.&lt;/p>
&lt;p>None of this is a defect. It is the right design decision for a piece that sits on the critical path of inference. What changes is what can be asserted with the resulting data.&lt;/p>
&lt;h2 id="what-cannot-be-promised-to-the-auditor">What cannot be promised to the auditor&lt;/h2>
&lt;p>A dashboard built on this pipeline answers statistical questions well: what is the p95 of time to first token for the model group, which team consumes the most, what proportion of requests fails, how cost per request evolves. That is what it is designed for, and that is what it is good for.&lt;/p>
&lt;p>It does not answer completeness questions well, which are the ones an ENS auditor or an ISO/IEC 42001 management system auditor asks. Faced with &amp;ldquo;show me that I have a record of every interaction with the AI system during March&amp;rdquo;, the honest answer is that the traces are a best-effort record with four documented points of silent dropping, with no dead-letter queue on the last hop and no durability guarantee on any of them.&lt;/p>
&lt;p>The clean separation is the one already proposed in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">technical controls ENS, 42001 and the AI Act&lt;/a>: the regulatory audit record is a different artefact, with a different write path, durability guarantees and retention of its own. In the pair this article deals with, the &lt;code>LiteLLM_SpendLogs&lt;/code> table in Postgres is a much better candidate than Langfuse for that function, because it is a transactional write against a relational database, and because it already contains &lt;code>request_id&lt;/code>, &lt;code>api_key&lt;/code> in hashed form, &lt;code>team_id&lt;/code>, &lt;code>organization_id&lt;/code>, &lt;code>end_user&lt;/code>, &lt;code>model&lt;/code>, tokens, &lt;code>startTime&lt;/code>, &lt;code>endTime&lt;/code>, &lt;code>status&lt;/code> and &lt;code>requester_ip_address&lt;/code>. Writing the audit event from the same place and in the same transaction as the spend is the sensible route; delegating it to the tracing pipeline is not.&lt;/p>
&lt;p>Here an operational tension appears that has to be resolved deliberately. LiteLLM&amp;rsquo;s performance recommendation for production includes &lt;code>disable_error_logs: true&lt;/code> and, in very loaded deployments, &lt;code>disable_spend_logs: true&lt;/code>, because provider errors inflate the table and concurrent writes against the same rows produce locks and exhaust the Postgres connections. Disabling &lt;code>spend_logs&lt;/code> to gain performance and at the same time leaning on that table for the audit record are incompatible decisions. Above a thousand requests per second or more than ten instances, the way out is &lt;code>use_redis_transaction_buffer: true&lt;/code>, which batches the spend updates in Redis before pushing them down to Postgres, watching the &lt;code>litellm_in_memory_spend_update_queue_size&lt;/code> and &lt;code>litellm_redis_spend_update_queue_size&lt;/code> metrics.&lt;/p>
&lt;h2 id="pii-switching-off-the-content-before-it-leaves">PII: switching off the content before it leaves&lt;/h2>
&lt;p>An LLM trace contains, by definition, the user&amp;rsquo;s prompt. In an on-premise deployment for a regulated customer, that content may be exactly what cannot leave the application&amp;rsquo;s domain, not even towards the observability backend in the same cluster.&lt;/p>
&lt;p>The controls on the proxy side, from most global to most fine-grained:&lt;/p>
&lt;ul>
&lt;li>&lt;code>litellm_settings.turn_off_message_logging: true&lt;/code> cuts messages and responses in every callback, and keeps the metadata and the spend. The redaction replaces the content with the &lt;code>REDACTED_BY_LITELLM&lt;/code> sentinel in messages, &lt;code>choices&lt;/code>, &lt;code>reasoning_content&lt;/code>, streaming deltas, tool call arguments and in the standard logging object.&lt;/li>
&lt;li>The &lt;code>x-litellm-enable-message-redaction: true&lt;/code> header enables redaction per request, and &lt;code>LiteLLM-Disable-Message-Redaction: true&lt;/code> disables it. The second is checked first and wins over everything else, including the global configuration, which makes it a control to block at the ingress if the policy is that content is never logged.&lt;/li>
&lt;li>&lt;code>redact_user_api_key_info: true&lt;/code> removes the hashed token, the &lt;code>user_id&lt;/code> and the &lt;code>team_id&lt;/code> from the log.&lt;/li>
&lt;li>Per request and per callback, &lt;code>metadata: {&amp;quot;mask_input&amp;quot;: true, &amp;quot;mask_output&amp;quot;: true}&lt;/code>.&lt;/li>
&lt;li>The body parameter &lt;code>&amp;quot;no-log&amp;quot;: true&lt;/code> disables logging of that call. It is a body parameter, not a header, and it can be disabled globally with &lt;code>global_disable_no_log_param: true&lt;/code> so a client cannot make itself invisible.&lt;/li>
&lt;/ul>
&lt;p>On the OTel v2 route the policy is inverted towards the safe side: content is off by default and is opened with &lt;code>OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT&lt;/code>, with values &lt;code>no_content&lt;/code>, &lt;code>span_only&lt;/code>, &lt;code>event_only&lt;/code> and &lt;code>span_and_event&lt;/code>. The documentation underlines that the gate is applied centrally and that a user&amp;rsquo;s request cannot force their prompt towards the backend while capture is disabled. It is also the stance the OpenTelemetry specification recommends, whose preferred pattern for production is to store the content in external storage and leave only the reference in the span.&lt;/p>
&lt;p>One specific version to check in the inventory: below LiteLLM 1.98.0, with per-team Langfuse credentials, the team&amp;rsquo;s own credentials ended up inside its traces. That version changed the origin of the metadata emitted to the &lt;code>StandardLoggingPayload&lt;/code> to cut it off. The same change removed a couple of dozen metadata fields (&lt;code>model_group&lt;/code>, &lt;code>deployment&lt;/code>, &lt;code>queue_time_seconds&lt;/code>, &lt;code>attempted_retries&lt;/code> and others), so it breaks saved dashboards and alerts that filter on them. It is an upgrade with work attached, and it has to be done anyway. The same applies to the general hardening of the stack, covered in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening and secrets of the sovereign stack&lt;/a>.&lt;/p>
&lt;h2 id="the-price-in-latency">The price in latency&lt;/h2>
&lt;p>LiteLLM publishes two overhead figures that differ by a factor of thirty, and both are theirs.&lt;/p>
&lt;p>The benchmarks page, with Locust, a thousand users with think time and a fake OpenAI endpoint, gives for four instances an &lt;code>x-litellm-overhead-duration-ms&lt;/code> of median 2 ms, p95 8 ms and p99 13 ms, at 1,170 requests per second. For two instances, median 12 ms, p95 29 ms and p99 43 ms. The page itself warns that those figures hold with around 130 in-flight requests, and that a closed-loop client with no think time keeps a thousand in flight and sees on the order of eight times that latency at the same rate, by Little&amp;rsquo;s law.&lt;/p>
&lt;p>The AIGatewayBench benchmark, of July 2026, measures the Python proxy at &lt;strong>257.7 ms of added p99&lt;/strong> with 329.5 MB of peak memory, against 0.7 ms for the Rust variant in beta. That measurement has no callbacks, no spend tracking and no persistence.&lt;/p>
&lt;p>The operational reading is that the first figure is the best case and the second the order of magnitude to plan for agentic load, which is the one that arrives in a closed loop against fast responses. To watch it there are two always-on headers: &lt;code>x-litellm-overhead-duration-ms&lt;/code> and &lt;code>x-litellm-callback-duration-ms&lt;/code>, and the documentation sets the useful threshold of the second at 100 ms, above which the diagnosis is that the payloads are too large.&lt;/p>
&lt;p>Three production settings that appear in LiteLLM&amp;rsquo;s documentation and that are wrong by default:&lt;/p>
&lt;p>&lt;strong>&lt;code>request_timeout&lt;/code> is 6,000 seconds.&lt;/strong> A hundred minutes holding a connection against a hung upstream. The recommendation is to lower it to 600.&lt;/p>
&lt;p>&lt;strong>&lt;code>LITELLM_LOG=DEBUG&lt;/code> serialises the payload with &lt;code>json.dumps(indent=4)&lt;/code> synchronously&lt;/strong>, and the documentation puts the cost of that serialisation at 2 to 5 seconds with payloads over 2 MB. It is the first cause of latency their troubleshooting guide mentions.&lt;/p>
&lt;p>&lt;strong>Background jobs register per uvicorn worker, not per pod.&lt;/strong> With &lt;code>--num_workers 4&lt;/code> and ten replicas that is forty copies of every periodic job. The &lt;code>LITELLM_JOB_ROLE&lt;/code> variable separates the pods that serve traffic from the replica that runs jobs.&lt;/p>
&lt;p>And one failure mode with an open issue that deserves to be in the runbook: under sustained 429s from the upstream, pods have been reported unable to answer readiness probes during startup, with the ensuing restart loop. It fits with what was covered in &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">incident response runbooks&lt;/a>.&lt;/p>
&lt;h2 id="start-up-checklist">Start-up checklist&lt;/h2>
&lt;p>Ten checks before signing off the installation of the pair:&lt;/p>
&lt;ol>
&lt;li>&lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code> instead of &lt;code>success_callback: [&amp;quot;langfuse&amp;quot;]&lt;/code>, unless you depend on prompt management, which the OTLP preset does not support.&lt;/li>
&lt;li>&lt;code>LANGFUSE_HOST&lt;/code> defined explicitly. The preset&amp;rsquo;s default value points to the US region.&lt;/li>
&lt;li>LiteLLM at 1.98.0 or higher, because of the team credential leak into the traces, and at 1.95.0 or higher because of the v4 ingestion header.&lt;/li>
&lt;li>A price registered for every self-hosted model, per token and in USD, verified with &lt;code>/spend/calculate&lt;/code> before opening traffic.&lt;/li>
&lt;li>A test request with a known &lt;code>traceparent&lt;/code>, checking that the same identifier appears in Langfuse.&lt;/li>
&lt;li>An OpenTelemetry Collector between vLLM and Langfuse, with &lt;code>OTEL_SERVICE_NAME&lt;/code> per engine and renaming of the old token attributes.&lt;/li>
&lt;li>&lt;code>request_timeout: 600&lt;/code> and &lt;code>LITELLM_LOG&lt;/code> out of &lt;code>DEBUG&lt;/code>.&lt;/li>
&lt;li>Tail sampling in the Collector, with a latency and status code policy, to keep 100 % of what is slow and what failed.&lt;/li>
&lt;li>A written decision about prompt content, and blocking of the &lt;code>LiteLLM-Disable-Message-Redaction&lt;/code> header at the ingress if the policy is not to log.&lt;/li>
&lt;li>A dashboard with &lt;code>x-litellm-overhead-duration-ms&lt;/code>, &lt;code>x-litellm-callback-duration-ms&lt;/code> and Langfuse&amp;rsquo;s ingestion queue depth (&lt;code>langfuse.queue.ingestion.depth&lt;/code>), which is the autoscaling signal for its workers.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>The trace arrives, but ten minutes late.&lt;/strong> The &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header is missing, which LiteLLM sends from 1.95.0. It is not a ClickHouse capacity problem.&lt;/p>
&lt;p>&lt;strong>The metadata works in development and disappears in production.&lt;/strong> Nginx drops headers with underscores by default, and all of this integration&amp;rsquo;s have one.&lt;/p>
&lt;p>&lt;strong>Session cost comes out as zero and generation cost does not.&lt;/strong> Langfuse adds up only the ingested cost in the session totals, not the one it computes itself.&lt;/p>
&lt;p>&lt;strong>Enabling OTel v2 breaks nothing visible and ungroups the conversations.&lt;/strong> The v2 mapper does not read &lt;code>session_id&lt;/code> or &lt;code>trace_user_id&lt;/code> from the request.&lt;/p>
&lt;p>&lt;strong>A team&amp;rsquo;s budget is never exhausted.&lt;/strong> Its spend is zero because the model has no price, and the calculation failure is only logged at debug level.&lt;/p>
&lt;p>&lt;strong>The alerts stop firing after the migration to v2.&lt;/strong> The success status becomes &lt;code>UNSET&lt;/code>; no rule on &lt;code>OK&lt;/code> is satisfied.&lt;/p>
&lt;p>&lt;strong>Forwarding the &lt;code>traceparent&lt;/code> is enabled and it does not reach the engine.&lt;/strong> The generic &lt;code>otel&lt;/code> callback is missing, without which the function that applies it returns before looking at the flag.&lt;/p>
&lt;p>&lt;strong>All the engines&amp;rsquo; spans appear under &lt;code>unknown_service&lt;/code>.&lt;/strong> vLLM emits no service name; &lt;code>OTEL_SERVICE_NAME&lt;/code> has to be set per deployment.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The gateway and the tracing backend are the two pieces of the inference stack that take the least time to install and the longest to operate well. The installation fits in a ConfigMap. The operation consists of four decisions no getting-started guide raises: which integration route to take knowing that one expires in November, where the price per token of a model nobody invoices comes from, what to do with the &lt;code>traceparent&lt;/code> at each hop, and what to tell an auditor about data the system drops by design when things get tight.&lt;/p>
&lt;p>The answer to the fourth is the one that orders the other three. This pipeline is operational instrumentation, with statistical precision and best-effort guarantees, and separating it from day one from the regulatory audit record avoids the awkward conversation two years from now. With that separation made, the LiteLLM and Langfuse pair is the pair of pieces that turns a fleet of GPUs into a platform with an owner, a known cost and explainable latency.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside: the sorting centre&lt;/a> — the six-service architecture and the ten backend knobs behind the receiving end of this integration. That post opens the box; this one connects the cable.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router: the L7 switchboard&lt;/a> — the router&amp;rsquo;s four functions and the minimal LiteLLM Proxy manifest on the generic cluster.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the OSS gateway for LLM inference&lt;/a> — the decision prior to this article, with the small print on licences and what is gated in LiteLLM.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a> — where the numbers that go into &lt;code>input_cost_per_token&lt;/code> and &lt;code>output_cost_per_token&lt;/code> come from, and why utilisation is the lever.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">From GPU-hour to cost per token and per request&lt;/a> — the full arithmetic of the conversion summarised here in one line.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">Instrumenting vLLM with OpenTelemetry&lt;/a> — the other end of the correlation, with the detail of what each &lt;code>gen_ai.latency.*&lt;/code> measures.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the &lt;code>SDK → Collector → backend&lt;/code> pipeline and the two-layer sampling this post takes as given.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS, ISO 42001 and the EU AI Act&lt;/a> — why the regulatory audit record cannot lean on a best-effort pipeline.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks&lt;/a> — where the diagnostic headers and the readiness probe failure mode under sustained 429s fit.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the complete journey of a request through every layer, of which this post details two.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">LiteLLM on day 2: high availability&lt;/a> — the process model, the probes, the retries and the 6,000-second timeout of the proxy that emits these traces.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a> — who can call and with what cap, and the audit table that does work as evidence when the traces do not.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a> — the gateway&amp;rsquo;s other four seams, including the identity that never reaches the trace.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">Langfuse v4: what goes into a trace&lt;/a> — the new data model at the receiving end, and the 16 November date with its small print.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Langfuse integration&lt;/em> (rutas de integración, metadata aceptada, cabeceras &lt;code>langfuse_*&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/observability/langfuse_integration">https://docs.litellm.ai/docs/observability/langfuse_integration&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>OpenTelemetry v2&lt;/em> y &lt;em>Migración a OTel v2&lt;/em> (preset, captura de contenido, cambios de semántica): &lt;a href="https://docs.litellm.ai/docs/observability/opentelemetry_v2">https://docs.litellm.ai/docs/observability/opentelemetry_v2&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/observability/opentelemetry_v2_migration">https://docs.litellm.ai/docs/observability/opentelemetry_v2_migration&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Logging&lt;/em> y &lt;em>Config settings&lt;/em> (redacción, &lt;code>turn_off_message_logging&lt;/code>, variables de entorno): &lt;a href="https://docs.litellm.ai/docs/proxy/logging">https://docs.litellm.ai/docs/proxy/logging&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/proxy/config_settings">https://docs.litellm.ai/docs/proxy/config_settings&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Custom pricing&lt;/em> y &lt;em>Spend tracking&lt;/em> (claves de precio, SpendLogs, endpoints de gasto): &lt;a href="https://docs.litellm.ai/docs/proxy/custom_pricing">https://docs.litellm.ai/docs/proxy/custom_pricing&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/proxy/cost_tracking">https://docs.litellm.ai/docs/proxy/cost_tracking&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Production best practices&lt;/em> (topología, &lt;code>request_timeout&lt;/code>, buffer de transacciones en Redis, &lt;code>LITELLM_JOB_ROLE&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/prod">https://docs.litellm.ai/docs/proxy/prod&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Benchmarks&lt;/em> y &lt;em>Latency overhead&lt;/em> (cifras de sobrecarga y cabeceras de diagnóstico): &lt;a href="https://docs.litellm.ai/docs/benchmarks">https://docs.litellm.ai/docs/benchmarks&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/troubleshoot/latency_overhead">https://docs.litellm.ai/docs/troubleshoot/latency_overhead&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Rust AI Gateway benchmarks&lt;/em> (la cifra de 257,7 ms de p99 del proxy Python): &lt;a href="https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks">https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks&lt;/a>.&lt;/li>
&lt;li>LiteLLM, notas de versión v1.95.0, v1.98.0 y v1.100.0: &lt;a href="https://docs.litellm.ai/release_notes/v1.95.0/v1-95-0">https://docs.litellm.ai/release_notes/v1.95.0/v1-95-0&lt;/a> · &lt;a href="https://docs.litellm.ai/release_notes/v1.98.0/v1-98-0">https://docs.litellm.ai/release_notes/v1.98.0/v1-98-0&lt;/a> · &lt;a href="https://docs.litellm.ai/release_notes/v1.100.0/v1-100-0">https://docs.litellm.ai/release_notes/v1.100.0/v1-100-0&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>OpenTelemetry&lt;/em> (endpoint, autenticación, tabla de mapeo de atributos, ausencia de gRPC): &lt;a href="https://langfuse.com/integrations/native/opentelemetry">https://langfuse.com/integrations/native/opentelemetry&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Compatibility&lt;/em> y &lt;em>Migrate v3 to v4&lt;/em> (la fecha del 16 de noviembre de 2026 y los modos de escritura): &lt;a href="https://langfuse.com/docs/compatibility">https://langfuse.com/docs/compatibility&lt;/a> · &lt;a href="https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v3-to-v4">https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v3-to-v4&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Token and cost tracking&lt;/em> (prioridad del coste ingerido sobre el inferido): &lt;a href="https://langfuse.com/docs/observability/features/token-and-cost-tracking">https://langfuse.com/docs/observability/features/token-and-cost-tracking&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Scaling&lt;/em> y &lt;em>Sampling&lt;/em> (dimensionado, señal de autoescalado, muestreo de cabecera): &lt;a href="https://langfuse.com/self-hosting/configuration/scaling">https://langfuse.com/self-hosting/configuration/scaling&lt;/a> · &lt;a href="https://langfuse.com/docs/observability/features/sampling">https://langfuse.com/docs/observability/features/sampling&lt;/a>.&lt;/li>
&lt;li>Langfuse, issue #13468 — descarte de filas en &lt;code>ClickhouseWriter&lt;/code> sin cola de mensajes muertos: &lt;a href="https://github.com/langfuse/langfuse/issues/13468">https://github.com/langfuse/langfuse/issues/13468&lt;/a>. Issue #15109 — coste de sesión a cero: &lt;a href="https://github.com/langfuse/langfuse/issues/15109">https://github.com/langfuse/langfuse/issues/15109&lt;/a>. Issue #5173 — Nginx y cabeceras con guión bajo: &lt;a href="https://github.com/langfuse/langfuse/issues/5173">https://github.com/langfuse/langfuse/issues/5173&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;code>vllm/tracing/utils.py&lt;/code> y &lt;code>vllm/tracing/otel.py&lt;/code> en la etiqueta v0.28.0 (atributos emitidos, protocolo por defecto, &lt;code>insecure=True&lt;/code>): &lt;a href="https://github.com/vllm-project/vllm/blob/v0.28.0/vllm/tracing/utils.py">https://github.com/vllm-project/vllm/blob/v0.28.0/vllm/tracing/utils.py&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>vLLM integration&lt;/em> (la advertencia de que vLLM solo exporta contadores y latencias): &lt;a href="https://langfuse.com/integrations/model-providers/vllm">https://langfuse.com/integrations/model-providers/vllm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/constants.py&lt;/code> y &lt;code>litellm/litellm_core_utils/logging_worker.py&lt;/code> (capacidades de las colas y política de descarte): &lt;a href="https://github.com/BerriAI/litellm/blob/main/litellm/constants.py">https://github.com/BerriAI/litellm/blob/main/litellm/constants.py&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, &lt;em>Semantic Conventions for Generative AI&lt;/em> y el repositorio &lt;code>semantic-conventions-genai&lt;/code> (estado de desarrollo, renombrados, captura de contenido): &lt;a href="https://github.com/open-telemetry/semantic-conventions-genai">https://github.com/open-telemetry/semantic-conventions-genai&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry Collector, &lt;code>tailsamplingprocessor&lt;/code> (políticas de muestreo por cola y la restricción de instancia única por traza): &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/tailsamplingprocessor/README.md">https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/tailsamplingprocessor/README.md&lt;/a>.&lt;/li>
&lt;li>llm-d, &lt;em>End-to-end and fine-grained tracing in llm-d&lt;/em> (&lt;code>OTEL_SERVICE_NAME&lt;/code> y los spans huérfanos tras Envoy): &lt;a href="https://llm-d.ai/blog/end-to-end-and-fine-grained-tracing-in-llm-d">https://llm-d.ai/blog/end-to-end-and-fine-grained-tracing-in-llm-d&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The blog map: from the utility feed to the token served</title><link>https://blog.lo0.es/en/posts/blog-map-from-utility-feed-to-token-served/</link><pubDate>Sat, 29 Aug 2026 10:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/blog-map-from-utility-feed-to-token-served/</guid><description>&lt;p>This blog started in March 2026 with a handful of articles about Kubernetes and networking, and by now it has piled up more than 150 pieces that, read one at a time, look like unconnected topics: PagedAttention, diesel generators, OpenCost, SPIFFE, immersion cooling, RAGAS. They are not. All of them describe the same object from different altitudes: &lt;strong>a sovereign inference factory&lt;/strong>, that is, the complete machine that turns megawatts from the grid into tokens served to a user, with ownership and control of the data kept in house.&lt;/p>
&lt;p>This article is the index of that machine. It orders what has been published into ten layers, bottom up, and links what is written in each one. It is good for three things: finding a specific topic, seeing which layers are covered in depth and which are half done, and choosing a reading route according to the problem in front of you.&lt;/p>
&lt;h2 id="the-map">The map&lt;/h2>
&lt;div class="diagram" style="max-width:880px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 880 620" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Layered map of the sovereign inference stack, from the utility feed to the token served">&lt;style>
.bx{fill:none;stroke:currentColor;stroke-width:1.3;rx:6}
.bxs{fill:none;stroke:currentColor;stroke-width:1;stroke-dasharray:4 3;rx:6}
.t{font:600 13px sans-serif;fill:currentColor}
.s{font:11px sans-serif;fill:currentColor}
.n{font:600 11px sans-serif;fill:currentColor}
.h{font:600 12px sans-serif;fill:currentColor}
.ar{stroke:currentColor;stroke-width:1.2;fill:none;marker-end:url(#ah)}
&lt;/style>
&lt;defs>&lt;marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="440" y="20" text-anchor="middle" class="t">From the utility feed to the token: the ten layers of an inference factory&lt;/text>
&lt;rect x="30" y="34" width="600" height="44" class="bx"/>
&lt;text x="46" y="53" class="h">10 · Cost, performance and energy&lt;/text>
&lt;text x="46" y="69" class="s">FinOps · benchmarking · Wh per token · TCO · the on-premise case with data&lt;/text>
&lt;text x="600" y="60" text-anchor="end" class="n">28&lt;/text>
&lt;rect x="30" y="86" width="290" height="44" class="bx"/>
&lt;text x="46" y="105" class="h">9 · Security and compliance&lt;/text>
&lt;text x="46" y="121" class="s">guardrails · isolation · signing · ENS, 42001, AI Act&lt;/text>
&lt;text x="304" y="112" text-anchor="end" class="n">13&lt;/text>
&lt;rect x="340" y="86" width="290" height="44" class="bx"/>
&lt;text x="356" y="105" class="h">8 · Observability and evals&lt;/text>
&lt;text x="356" y="121" class="s">OTel GenAI · Langfuse · DCGM · RAGAS · runbooks&lt;/text>
&lt;text x="614" y="112" text-anchor="end" class="n">14&lt;/text>
&lt;rect x="30" y="138" width="600" height="44" class="bx"/>
&lt;text x="46" y="157" class="h">7 · Control plane and delivery&lt;/text>
&lt;text x="46" y="173" class="s">gateway and L7 router · KServe and OIP · OCI registry · GitOps · canary and shadow&lt;/text>
&lt;text x="600" y="164" text-anchor="end" class="n">14&lt;/text>
&lt;rect x="30" y="190" width="600" height="44" class="bx"/>
&lt;text x="46" y="209" class="h">6 · Data and RAG&lt;/text>
&lt;text x="46" y="225" class="s">ingestion · embeddings · reranker · vector store · CDC · semantic cache&lt;/text>
&lt;text x="600" y="216" text-anchor="end" class="n">17&lt;/text>
&lt;rect x="30" y="242" width="600" height="44" class="bx"/>
&lt;text x="46" y="261" class="h">5 · The model&lt;/text>
&lt;text x="46" y="277" class="s">quantisation · pruning · distillation · MoE · LoRA · speculative decoding&lt;/text>
&lt;text x="600" y="268" text-anchor="end" class="n">20&lt;/text>
&lt;rect x="30" y="294" width="600" height="44" class="bx"/>
&lt;text x="46" y="313" class="h">4 · Inference engine&lt;/text>
&lt;text x="46" y="329" class="s">vLLM from the inside · KV cache · prefill and decode · batching · scheduler&lt;/text>
&lt;text x="600" y="320" text-anchor="end" class="n">22&lt;/text>
&lt;rect x="30" y="346" width="600" height="44" class="bx"/>
&lt;text x="46" y="365" class="h">3 · Cluster&lt;/text>
&lt;text x="46" y="381" class="s">Kubernetes · GPU sharing · queues and quotas · autoscaling · operators&lt;/text>
&lt;text x="600" y="372" text-anchor="end" class="n">13&lt;/text>
&lt;rect x="30" y="398" width="290" height="44" class="bx"/>
&lt;text x="46" y="417" class="h">2 · Storage&lt;/text>
&lt;text x="46" y="433" class="s">weights, corpus and checkpoints · cold start&lt;/text>
&lt;text x="304" y="424" text-anchor="end" class="n">7&lt;/text>
&lt;rect x="340" y="398" width="290" height="44" class="bx"/>
&lt;text x="356" y="417" class="h">1 · Node&lt;/text>
&lt;text x="356" y="433" class="s">PCIe · NUMA · NVLink · HBM · CPU&lt;/text>
&lt;text x="614" y="424" text-anchor="end" class="n">8&lt;/text>
&lt;rect x="30" y="450" width="600" height="44" class="bx"/>
&lt;text x="46" y="469" class="h">0 · Physical infrastructure&lt;/text>
&lt;text x="46" y="485" class="s">utility feed · generators · switching · UPS · air · liquid · immersion&lt;/text>
&lt;text x="600" y="476" text-anchor="end" class="n">8&lt;/text>
&lt;path d="M660 472 L660 56" class="ar"/>
&lt;text x="672" y="270" class="s">each layer only exists&lt;/text>
&lt;text x="672" y="286" class="s">if the one below holds it&lt;/text>
&lt;rect x="660" y="330" width="200" height="120" class="bxs"/>
&lt;text x="676" y="352" class="h">Master maps&lt;/text>
&lt;text x="676" y="372" class="s">6-stage LLMOps pipeline&lt;/text>
&lt;text x="676" y="390" class="s">the 7 layers of the stack&lt;/text>
&lt;text x="676" y="408" class="s">anatomy of a request&lt;/text>
&lt;text x="676" y="426" class="s">5 maturity levels&lt;/text>
&lt;text x="676" y="444" class="s">7 deployment phases&lt;/text>
&lt;rect x="660" y="86" width="200" height="96" class="bxs"/>
&lt;text x="676" y="108" class="h">Networking vertical&lt;/text>
&lt;text x="676" y="128" class="s">EVPN-VXLAN Huawei&lt;/text>
&lt;text x="676" y="146" class="s">MikroTik RouterOS 7&lt;/text>
&lt;text x="676" y="164" class="s">UniFi and HCIA-Datacom&lt;/text>
&lt;text x="440" y="520" text-anchor="middle" class="s">Layer 10 rests on none of the others: it cuts through them all, because it measures what the nine below cost.&lt;/text>
&lt;text x="440" y="540" text-anchor="middle" class="s">Layers 0 to 3 belong to the infrastructure operator; 4 to 7, to the platform; 8 and 9, to everyone.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The map reads bottom up, and the criterion for ordering it is physical dependency: there is no inference engine without a powered and cooled GPU, no RAG without an engine serving embeddings, no demonstrable regulatory compliance without observability recording what happened. Layer 10 is the exception, and that is why it appears cutting across everything: cost, performance and energy are not a floor of the building, they are the measure of the whole building.&lt;/p>
&lt;h2 id="layer-0--physical-infrastructure">Layer 0 · Physical infrastructure&lt;/h2>
&lt;p>The most recent vertical, and the one that closes the circle: the path of the watt from the utility feed to the chip, and the path of the heat from the chip to the atmosphere.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/datacenter-physical-infrastructure-power-chain/">The power chain&lt;/a>: redundancy, Tier, kW against kVA, PUE, AI density and the limit of the grid.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/datacenter-physical-infrastructure-generators/">Generators&lt;/a>: NFPA 110, transients, sizing, diesel against gas, fuel and paralleling.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/datacenter-physical-infrastructure-switching/">Switching&lt;/a>: ATS against STS, the ITIC window, open and closed transition, and the A and B paths.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/datacenter-physical-infrastructure-ups-and-ride-through/">The UPS and ride-through&lt;/a>: IEC 62040-3 topologies, eco-mode, flywheel, lithium against lead and the pulsing loads of AI.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ai-datacenter-cooling-thermal-challenge/">The thermal challenge&lt;/a>: why AI density broke air, and the spectrum of solutions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ai-datacenter-cooling-air-and-its-limits/">Air and its limits&lt;/a>: CRAC and CRAH, the geometry of the flow, the cube law, containment, ASHRAE and rear doors.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ai-datacenter-cooling-direct-liquid-to-chip/">Direct liquid to the chip&lt;/a>: cold plate, CDU, TCS and FWS loops, coolant chemistry and leaks.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ai-datacenter-cooling-immersion-megawatt-horizon/">Immersion and the megawatt horizon&lt;/a>: single-phase and two-phase, the PFAS wall and microfluidics in the silicon.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-1--the-node">Layer 1 · The node&lt;/h2>
&lt;p>Inside the machine, before any process exists: the buses, the memory and the topology that decide how much performance actually reaches the model.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">PCIe topology, GPUDirect and ACS&lt;/a>: the corridors the tensors travel through.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a>: why the neighbour on your socket costs you latency.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL&lt;/a>: the cable a large model is split across.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">Network NUMA with Cilium and DraNet&lt;/a>: the kitchen door nobody watches.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">CPU, Memory and Topology Manager in RKE2&lt;/a>: how to ask for a GPU with affinity and no surprises.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SM, CUDA streams and CUDA graphs&lt;/a>: where time is lost inside the accelerator.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA and Intel environments&lt;/a>: from the homogeneous cluster to the real estate.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/measuring-gpu-power-nvml-dcgm-sampling-errors/">Measuring the power of a GPU&lt;/a>: sensor metrology, which belongs to this layer and to layer 10 at the same time.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-2--storage">Layer 2 · Storage&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ai-era-storage-state-of-the-art/">State of the art&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/ai-era-storage-performance/">performance&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/storage-ai-era-security/">security&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/storage-ai-era-availability/">availability&lt;/a>: the set of four on the storage that holds weights, corpus and checkpoints.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>: why starting a model takes as long as it takes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up the cold start&lt;/a>: from minutes to seconds with serialisation and cache.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-3--the-cluster">Layer 3 · The cluster&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>: the inference piece that does scale.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM inference operators&lt;/a>: OME, vLLM Production Stack and company.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">The GPU cluster as a multi-tenant platform&lt;/a>: from shared machine to service.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a>: the three ways to split an accelerator.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/volcano-kueue-gang-scheduling-gpu-quotas-kubernetes/">Volcano and Kueue&lt;/a>: gang scheduling, queues and quotas.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling with HPA and KEDA&lt;/a>: scaling on metrics that mean something.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on one GPU&lt;/a>: co-residency, swap and sleep.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">On-premise capacity planning&lt;/a>: from the SLO to the number of nodes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the stack with Flux&lt;/a>: operating the platform as code.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kubernetes-cilium-bgp-services-without-ingress/">Cilium BGP on RKE2&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>: the network underneath everything above.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-4--the-inference-engine">Layer 4 · The inference engine&lt;/h2>
&lt;p>The densest block of the blog: vLLM cut open, piece by piece.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>: the working memory and its block manager.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">the scheduler step&lt;/a>: how each round of work is assembled.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Optimising prefill&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">optimising decode&lt;/a>: the two phases and their controls.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/">The attention backend&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention&lt;/a>: the core of the computation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache: hit rate engineering&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">batch sizing with grid search&lt;/a>: the two settings that give away the most performance.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">one big replica or many small ones&lt;/a>: the topology decisions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/long-context-kv-offloading-notebook-desk/">Long context and KV offloading&lt;/a>: the KV memory hierarchy when the window does not fit in HBM.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/function-calling-tool-augmented-retrieval-detective-knows-which-file/">function calling&lt;/a>: holding the model&amp;rsquo;s output in place.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/serving-reasoning-models-invisible-scratchpad-latency-bill/">Serving reasoning models&lt;/a>: the invisible draft you pay for all the same.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal on-premise with vLLM&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/">serving embeddings and rerankers with TEI&lt;/a>: the other models in the stack.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The inverted roofline&lt;/a>: why a small model is not optimised the same way as a large one.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-5--the-model">Layer 5 · The model&lt;/h2>
&lt;p>Everything done to the model before serving it, or while it is being served.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for inference&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end-to-end&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">aggressive sub-4-bit quantisation&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/test-time-quantization-without-calibration-dataset/">quantising on the fly&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/">Pruning&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">distillation&lt;/a>: reducing without amputating, and teaching a small model.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE in inference&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">device-native architectures&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and multi-LoRA at the limit&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/qlora-runbook-dataset-to-served-adapter/">the complete QLoRA runbook&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/self-speculative-decoding-model-gets-ahead-of-itself/">self-speculative with early exit&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment&lt;/a>: DPO, KTO, ORPO and SimPO.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain: closing the loop&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-6--data-and-rag">Layer 6 · Data and RAG&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">Document ingestion from the PDF to the indexed chunk&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">corpus curation&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings in 2026&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">reranker with hybrid retrieval&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL and Qdrant in ingestion&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/debezium-cdc-notary-who-hears-changes/">Debezium and CDC&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/semantic-cache-rag-receptionist-photographic-memory/">Semantic cache&lt;/a>: the receptionist with a memory.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">RAG on CPU&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/">aggressive RAG on small models&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evaluating-rag-ragas-golden-dataset-metrics/">Evaluating a RAG without fooling yourself&lt;/a>: RAGAS and the golden dataset.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ontologies-knowledge-graphs-llmops-linnaean-nomenclature/">Ontologies and knowledge graphs&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning with DVC and lakeFS&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-7--control-plane-and-delivery">Layer 7 · Control plane and delivery&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the OSS gateway&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 inference router&lt;/a>: the switchboard in front of the engine.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">KServe and the Open Inference Protocol&lt;/a>: the standard control plane.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Model registry and distribution with OCI and ORAS&lt;/a>: where the bytes live.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a>: deploying a model without breaking the service.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning&lt;/a>: the contract that avoids the scare.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">adding authentication with Keycloak&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Operating the control plane&lt;/strong> (September 2026 run): &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the LiteLLM and Langfuse pair&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2 of the proxy&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys, budgets and limits&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents on the same gateway&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">the prefix routing LiteLLM does not do&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">the MCP gateway as a second front door&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">sizing for agents&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">the seams with identity and traces&lt;/a>: the gateway no longer as a piece you choose, but as a piece you operate.&lt;/li>
&lt;li>&lt;strong>Identity&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>, with its position in the architecture and the MCP standards it does not implement, and &lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">how to complete it for MCP&lt;/a> by building the protected resource side.&lt;/li>
&lt;li>&lt;strong>Operating Langfuse&lt;/strong> (a run of eight, September 2026): &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what goes into a trace&lt;/a>, the first article about day 2 of version 4.&lt;/li>
&lt;li>Building an end-to-end sovereign assistant, with all of the above pieces working together, is the exercise that closes this layer and is pending publication.&lt;/li>
&lt;/ul>
&lt;p>Delivery to the teams that use it is its own family of pieces:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/backstage-self-service-portal-llm-platform/">Backstage as a self-service portal&lt;/a>: the catalogue, the templates and the golden path, with the real cost of operating it.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kubeflow-pieces-worth-it-on-premise-llm-platform/">Kubeflow in depth&lt;/a>: which pieces of the ML toolbox are worth it and which ones you already have.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knative-scale-to-zero-llm-inference/">Knative and scale-to-zero&lt;/a>: when switching off the GPU saves money and when the cold start violates your SLO.&lt;/li>
&lt;/ul>
&lt;p>Rebuilding the cloud experience on your own iron is a run of three:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/public-cloud-to-private-what-you-lose-which-projects-fill-gap/">From public cloud to private&lt;/a>: what you lose when you leave the cloud and which projects fill each gap, with the business case and the sovereignty drivers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/crossplane-control-plane-private-cloud-api/">Crossplane as a control plane&lt;/a>: the declarative provisioning API that on-premise does not give you, and the cost of operating your own control plane.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kubevela-score-platform-cli-without-teaching-kubernetes/">KubeVela and Score&lt;/a>: application-centric self-service by CLI, the friendly face Crossplane deliberately does not offer.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-8--observability-and-evaluation">Layer 8 · Observability and evaluation&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">Tracing with OpenTelemetry GenAI&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">instrumenting vLLM with OTel&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a>: the sorting centre for traces.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">the anatomy of DCGM and vLLM metrics&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks&lt;/a>: from the alert to the action.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">drift detection with eBPF&lt;/a>: observing without instrumenting.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-9--security-trust-and-compliance">Layer 9 · Security, trust and compliance&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a>: the lines of defence around the model.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign stack&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents from the workspace&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">the runbook with bubblewrap and Tetragon&lt;/a>, with &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a> as the base.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-signature-provenance-aibom-why-you-trust-those-bytes/">Signing, provenance and AIBOM&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">identity and isolation with SPIFFE and Confidential Containers&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">the EU AI Act article by article&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">the cross-mapping of ENS, 42001 and the AI Act&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/infrastructure-healthcare-ai-compliance/">Compliance in healthcare&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/infrastructure-defence-ai-compliance/">in defence&lt;/a>: the same infrastructure facing two sector frameworks.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-10--cost-performance-and-energy">Layer 10 · Cost, performance and energy&lt;/h2>
&lt;p>The data track, written in a different format from the rest of the blog: tables, figures and a verifiable source, no analogies. These are the articles that hold up an investment decision.&lt;/p>
&lt;p>&lt;strong>FinOps&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/gpu-llm-finops-frameworks-metrics-state-of-the-art/">cost model and state of the art&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/opencost-gpu-cost-allocation-kubernetes/">OpenCost in depth&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/kubecost-vs-opencost-vs-alternatives/">Kubecost against OpenCost&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">from the GPU-hour to the cost per token&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/gpu-chargeback-showback-multi-tenancy/">chargeback and showback&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">the cost of the idle GPU&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/cloud-gpu-on-demand-reserved-spot-neoclouds-2026-prices/">cloud GPU and neoclouds&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/full-tco-on-premise-gpu-cluster-capex-gpu-hour-break-even/">full on-premise TCO&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">multi-tenancy FinOps with LiteLLM&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Benchmarking&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/llm-inference-benchmarking-frameworks-metrics-state-of-the-art/">frameworks and metrics&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-benchmark-tool-catalogue-practical-deep-dive/">the tool catalogue&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/">GuideLLM and the SLO under load&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/genai-perf-llm-inference-profiling-in-depth/">GenAI-Perf in depth&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mlperf-inference-reading-loadgen-comparability/">how to read MLPerf Inference&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/measurement-bias-reproducibility-llm-benchmarks/">measurement bias and reproducibility&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-quality-benchmarks-contamination-trap-oss-tools/">quality benchmarks&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">the Pareto frontier between engines&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Energy&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/measuring-gpu-power-nvml-dcgm-sampling-errors/">measuring GPU power&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/energy-per-token-spain-methodology-electricity-market-speculation/">energy per token in Spain&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/ai-energy-benchmarking-frameworks-metrics-state-of-the-art/">energy benchmarking frameworks&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/measuring-energy-in-production-kepler-dcgm-stack/">measuring in production with Kepler and DCGM&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mlperf-power-energy-efficiency-benchmark/">MLPerf Power&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-energy-leaderboards-wh-per-token/">Wh per token leaderboards&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/watt-to-carbon-pue-grid-intensity-gco2-per-token/">from the watt to carbon&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/levers-fewer-watts-per-token-llm-inference/">efficiency levers&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/energy-in-tco-and-regulation-cost-pue-csrd-eu-datacenter-reporting/">energy in the TCO and in regulation&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Synthesis&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/cost-performance-energy-three-axes-llm-inference/">the three axes and the identity that binds them&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/sovereign-on-premise-vs-hyperscalers-data/">sovereign on-premise against hyperscalers, with data&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/slo-to-number-of-gpus-sizing-justifying-inference-hardware/">from the SLO to the number of GPUs&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/reproducible-harness-cost-performance-energy-experiment/">the reproducible harness&lt;/a>.&lt;/p>
&lt;p>To that list you can add two cost pieces that are not about GPUs: &lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">the cost of agents with durable execution&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">the gateway&amp;rsquo;s cost per request&lt;/a>.&lt;/p>
&lt;h2 id="the-master-maps">The master maps&lt;/h2>
&lt;p>Five earlier articles work as the general plan and are still the best way in for anyone arriving new:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a>: the master map of the life cycle.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The seven layers of the on-premise stack&lt;/a>: the architecture version of this same map.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a>: a real request crossing every layer.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels of the platform&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">seven greenfield deployment phases&lt;/a>: where you are and what comes next.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue entry by entry&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">the parallel catalogue against the hyperscalers&lt;/a>: which tool for each stage.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps for LLMs in 2026&lt;/a>: the starting landscape.&lt;/li>
&lt;/ul>
&lt;h2 id="four-routes">Four routes&lt;/h2>
&lt;p>&lt;strong>I am about to build the first platform.&lt;/strong> Landscape and the six-stage pipeline, the seven layers of the stack, the seven deployment phases, capacity planning, vLLM on Kubernetes, gateway, observability with OTel and Langfuse, and guardrails before opening the door.&lt;/p>
&lt;p>&lt;strong>I have the cluster and the performance is not there.&lt;/strong> Anatomy of a request, KV cache and PagedAttention, prefill and decode, prefix cache, batch sizing, GuideLLM to find the saturation point, measurement bias so you do not fool yourself with the numbers, and the Pareto frontier between engines.&lt;/p>
&lt;p>&lt;strong>I have to justify the investment to management.&lt;/strong> The three axes, on-premise TCO, cost per token, idle GPU, on-premise against hyperscalers, sizing and justifying, and the reproducible harness so the figures survive an audit.&lt;/p>
&lt;p>&lt;strong>My job is the room, not the software.&lt;/strong> The power chain, the generators, the thermal challenge, air and its limits, direct liquid to the chip, immersion, and from there to storage and node topology.&lt;/p>
&lt;h2 id="what-is-missing">What is missing&lt;/h2>
&lt;p>The map also serves to show the gaps, and three are identified, plus a line that is half open:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>A comparison of vector databases&lt;/strong>: Milvus, Qdrant, pgvector and OpenSearch with data, beyond the scattered mentions.&lt;/li>
&lt;li>&lt;strong>Lakehouse for RAG&lt;/strong>: Iceberg and Trino as the substrate of the corpus.&lt;/li>
&lt;li>&lt;strong>Out-of-band management&lt;/strong>: OpenBMC, Redfish and DCIM, which is exactly the bridge between layer 0 and layer 3.&lt;/li>
&lt;li>&lt;strong>Sovereign substrate&lt;/strong>: the CLI self-service part is already covered by the layer 7 run (Crossplane, KubeVela and Score), and what remains is the IaaS-type substrate underneath, the sovereign alternatives to the hyperscaler (Spinifex, Harvester, OpenStack) on which everything else runs.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/start-here/">Start here&lt;/a>: the reading route by profile and by vertical, including the Kubernetes and networking ones this map leaves out.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a>: if you are only going to read one article on the blog, make it that one.&lt;/li>
&lt;/ul></description></item><item><title>Langfuse from the inside: the sorting centre that must not become the bottleneck it came to observe</title><link>https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/</link><pubDate>Sat, 06 Jun 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/</guid><description>&lt;blockquote>
&lt;p>This post closes a trilogy of the &lt;strong>Observe&lt;/strong> layer: in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> the &lt;code>SDK → Collector → backend&lt;/code> pipeline was set up and Langfuse was treated as a black box that receives spans; in &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> its prompt management layer was used. Here we open the box: what is inside Langfuse, why v3 stopped being a monolith on Postgres, and how it is operated so it holds the traffic of an inference cluster without becoming the problem.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Langfuse v3 (stable since December 2024) &lt;strong>is not an application, it is six services&lt;/strong>: two containers of its own (Web and Worker) and four stateful dependencies (Postgres, ClickHouse, Redis/Valkey and an S3-compatible blob store). The key architectural change compared with v2, which was a Next.js monolith on Postgres, is the &lt;strong>asynchronous ingestion pipeline&lt;/strong>: traces are received in batches, written &lt;em>immediately&lt;/em> to S3, only a &lt;em>reference&lt;/em> is queued in Redis, and a Worker ingests them into ClickHouse in the background. This decouples reception speed (limited only by Redis write latency, ~1-5 ms) from the cost of persisting and merging into the analytical database. The result: the Web container sustains hundreds of events per second without a spike blocking the client that serves the inference. But that design only performs with the right settings. This post covers the architecture, its interaction with the rest of the on-premise stack, and &lt;strong>ten backend knobs&lt;/strong>, from batching to ClickHouse to queue sharding, from the &lt;code>FINAL&lt;/code> modifier to system log table hygiene, that decide the real throughput and the storage cost. And it marks where the async design hides data loss windows worth knowing about before promising &amp;ldquo;full traceability&amp;rdquo;.&lt;/p>
&lt;h2 id="you-are-here-observe-and-the-layer-that-holds-up-the-rest">You are here: OBSERVE (and the layer that holds up the rest)&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Observe">
&lt;defs>&lt;marker id="lfm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">You are here: OBSERVE · the storage substrate that makes tracing operable&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="85" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="210" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="335" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="460" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" rx="6" fill="#c9a8e9" stroke="#444" stroke-width="3"/>&lt;text x="585" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="710" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">6 · Retrain&lt;/text>
&lt;path d="M140,52 L155,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;path d="M265,52 L280,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;path d="M390,52 L405,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;path d="M515,52 L530,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;path d="M640,52 L655,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-postal-sorting-centre">The analogy: the postal sorting centre&lt;/h2>
&lt;p>Picture the central mail sorting office of a large city at rush hour. Lorries loaded with &lt;strong>sacks&lt;/strong> (batches of letters) arrive at a rate that does not stop. If the counter clerk had to &lt;strong>open every sack, read every letter, decide its destination and file it&lt;/strong> before accepting the next lorry, the queue of lorries would go round the block in ten minutes. No serious sorting centre works like that.&lt;/p>
&lt;p>What they do is &lt;strong>decouple reception from processing&lt;/strong>:&lt;/p>
&lt;ol>
&lt;li>The &lt;strong>reception counter&lt;/strong> accepts the sack, stamps a receipt on it, leaves it in a &lt;strong>pigeonhole&lt;/strong> in the warehouse and drops a &lt;strong>ticket&lt;/strong> on a conveyor belt. Time per sack: seconds. The counter never blocks.&lt;/li>
&lt;li>Further back, in the &lt;strong>sorting room&lt;/strong>, a team of operators picks tickets off the belt, retrieves the sack from its pigeonhole, opens it, sorts the letters and files them in the &lt;strong>permanent archive&lt;/strong>, ordered, indexed, searchable.&lt;/li>
&lt;/ol>
&lt;p>Langfuse v3 is exactly this sorting centre:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Postal centre&lt;/th>
&lt;th>Langfuse v3&lt;/th>
&lt;th>Function&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Reception counter&lt;/td>
&lt;td>&lt;strong>Web&lt;/strong> container (ingestion endpoint)&lt;/td>
&lt;td>Accepts event batches, gives an immediate receipt (HTTP 207)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pigeonhole warehouse&lt;/td>
&lt;td>&lt;strong>S3 / Blob store&lt;/strong> (MinIO on-prem)&lt;/td>
&lt;td>Stores the raw sack (the full event)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ticket on the belt&lt;/td>
&lt;td>&lt;strong>Redis / Valkey&lt;/strong> (BullMQ queue)&lt;/td>
&lt;td>Only the &lt;em>reference&lt;/em> to the object in S3, not the content&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sorting room&lt;/td>
&lt;td>&lt;strong>Worker&lt;/strong> container&lt;/td>
&lt;td>Takes tickets, reads S3, transforms and files&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Indexed permanent archive&lt;/td>
&lt;td>&lt;strong>ClickHouse&lt;/strong> (OLAP)&lt;/td>
&lt;td>Traces, observations and scores, queryable by project+time&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Administrative register&lt;/td>
&lt;td>&lt;strong>Postgres&lt;/strong> (OLTP)&lt;/td>
&lt;td>Users, projects, API keys, prompts, datasets, config&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The thesis of the whole post follows from this analogy: &lt;strong>the value of Langfuse lies in the counter never blocking the client that serves the inference&lt;/strong>. An observability tool that adds latency or outages to the token-serving path is worse than having no observability, because it degrades precisely the system it set out to look after. The whole of the v3 design, and every knob in this post, exists to keep that promise under load.&lt;/p>
&lt;h2 id="the-mechanism-itself-six-services-two-planes">The mechanism itself: six services, two planes&lt;/h2>
&lt;p>Langfuse v3 separates two planes that in v2 were fused together:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ingestion and query plane&lt;/strong> (the two containers of its own, &lt;em>stateless&lt;/em>, horizontally scalable): Web and Worker.&lt;/li>
&lt;li>&lt;strong>State plane&lt;/strong> (four dependencies, each with its own load profile): Postgres (transactional OLTP), ClickHouse (analytical OLAP), Redis/Valkey (queue + cache), Blob store (raw objects).&lt;/li>
&lt;/ul>
&lt;div class="diagram" style="max-width:820px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 820 470" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Six-service architecture of Langfuse v3">
&lt;defs>&lt;marker id="lfa" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="24" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="currentColor">Langfuse v3 · six services, two planes&lt;/text>
&lt;!-- Clients -->
&lt;rect x="30" y="50" width="150" height="60" rx="8" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.6"/>
&lt;text x="105" y="74" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">SDKs / OTel&lt;/text>
&lt;text x="105" y="92" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#444">apps, vLLM, gateway&lt;/text>
&lt;!-- Stateless plane -->
&lt;rect x="240" y="44" width="300" height="150" rx="10" fill="none" stroke="#999" stroke-width="1.2" stroke-dasharray="5 3"/>
&lt;text x="390" y="40" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="currentColor">Stateless plane (horizontal scale)&lt;/text>
&lt;rect x="262" y="62" width="120" height="58" rx="8" fill="#dceede" stroke="#3c8c54" stroke-width="1.8"/>
&lt;text x="322" y="84" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">Web&lt;/text>
&lt;text x="322" y="101" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">ingestion + UI/API&lt;/text>
&lt;rect x="398" y="62" width="120" height="58" rx="8" fill="#dceede" stroke="#3c8c54" stroke-width="1.8"/>
&lt;text x="458" y="84" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">Worker&lt;/text>
&lt;text x="458" y="101" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">sorts → CH&lt;/text>
&lt;rect x="262" y="135" width="256" height="46" rx="8" fill="#f7efda" stroke="#c79a32" stroke-width="1.6"/>
&lt;text x="390" y="153" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">Redis / Valkey&lt;/text>
&lt;text x="390" y="170" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">BullMQ queue (refs) + API key/prompt cache&lt;/text>
&lt;!-- State plane -->
&lt;rect x="240" y="232" width="540" height="200" rx="10" fill="none" stroke="#999" stroke-width="1.2" stroke-dasharray="5 3"/>
&lt;text x="510" y="228" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="currentColor">State plane&lt;/text>
&lt;rect x="262" y="250" width="150" height="74" rx="8" fill="#f3dede" stroke="#b35454" stroke-width="1.6"/>
&lt;text x="337" y="274" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Blob store (S3)&lt;/text>
&lt;text x="337" y="291" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">MinIO on-prem&lt;/text>
&lt;text x="337" y="306" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">raw events + media&lt;/text>
&lt;rect x="437" y="250" width="150" height="74" rx="8" fill="#dde6f3" stroke="#4a6fa5" stroke-width="1.6"/>
&lt;text x="512" y="274" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">ClickHouse&lt;/text>
&lt;text x="512" y="291" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">OLAP · traces,&lt;/text>
&lt;text x="512" y="306" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">observations, scores&lt;/text>
&lt;rect x="612" y="250" width="150" height="74" rx="8" fill="#e6ddf3" stroke="#7a5aa5" stroke-width="1.6"/>
&lt;text x="687" y="274" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Postgres&lt;/text>
&lt;text x="687" y="291" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">OLTP · orgs, users,&lt;/text>
&lt;text x="687" y="306" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">API keys, prompts&lt;/text>
&lt;!-- ingestion flow -->
&lt;text x="510" y="356" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="600" fill="#3c8c54">Ingestion route (asynchronous)&lt;/text>
&lt;text x="510" y="376" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">① Web writes event → S3 ② Web queues ref → Redis&lt;/text>
&lt;text x="510" y="394" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">③ Worker pulls ref from Redis ④ reads S3 → ⑤ inserts into ClickHouse&lt;/text>
&lt;text x="510" y="416" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">UI/API: Web reads from ClickHouse (traces) + Postgres (config)&lt;/text>
&lt;!-- connections -->
&lt;path d="M180,80 L262,84" fill="none" stroke="#666" stroke-width="1.6" marker-end="url(#lfa)"/>
&lt;text x="218" y="72" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#666">batch&lt;/text>
&lt;path d="M322,120 L322,135" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M390,181 L420,232" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M458,135 L458,120" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M458,120 L458,135" fill="none" stroke="#666" stroke-width="1.4"/>
&lt;path d="M412,287 L437,287" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M448,120 L500,250" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M518,120 L660,250" fill="none" stroke="#666" stroke-width="1.2" marker-end="url(#lfa)" stroke-dasharray="3 2"/>
&lt;/svg>
&lt;/div>
&lt;p>What to take away from this diagram:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Web and Worker are interchangeable and stateless.&lt;/strong> They store nothing locally. You can run 1 or 20 replicas of each; the state lives in the four dependencies. This is what allows scaling by load without choreography.&lt;/li>
&lt;li>&lt;strong>Redis never carries the event content, only the reference&lt;/strong> to the object in S3. That is why Redis holds the spike: a Redis write is ~1-5 ms and moves bytes, not kilobytes. The bottleneck of the Web container is, literally, &lt;em>Redis write speed&lt;/em>.&lt;/li>
&lt;li>&lt;strong>Postgres and ClickHouse have opposite profiles.&lt;/strong> Postgres is OLTP: many small transactional reads and writes (is this API key valid? which version does the &lt;code>production&lt;/code> label point to?). ClickHouse is OLAP: few enormous batch writes and analytical queries over billions of rows (give me the p95 TTFT of project X over the last 7 days). Putting traces in Postgres, which is what v2 did, works until it does not: at production volume, Postgres drowns in a workload it was not designed for. That was the reason for the redesign.&lt;/li>
&lt;/ul>
&lt;h2 id="the-ingestion-flow-step-by-step-and-the-maths-of-the-decoupling">The ingestion flow step by step (and the maths of the decoupling)&lt;/h2>
&lt;p>The heart of the design is the ingestion route. Seen in detail, a &lt;code>POST /api/public/ingestion&lt;/code> request with a batch of events does this:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 780 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Langfuse asynchronous ingestion sequence">
&lt;defs>&lt;marker id="lfs" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;!-- lifelines -->
&lt;text x="80" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="currentColor">Client&lt;/text>
&lt;text x="250" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#3c8c54">Web&lt;/text>
&lt;text x="420" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#b35454">S3&lt;/text>
&lt;text x="560" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#c79a32">Redis&lt;/text>
&lt;text x="700" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#4a6fa5">CH+Worker&lt;/text>
&lt;line x1="80" y1="40" x2="80" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;line x1="250" y1="40" x2="250" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;line x1="420" y1="40" x2="420" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;line x1="560" y1="40" x2="560" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;line x1="700" y1="40" x2="700" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;path d="M80,60 L250,60" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="165" y="54" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">POST event batch&lt;/text>
&lt;path d="M250,85 L420,85" fill="none" stroke="#b35454" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="335" y="79" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">① writes raw event&lt;/text>
&lt;path d="M250,110 L560,110" fill="none" stroke="#c79a32" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="405" y="104" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">② queues REFERENCE (not the event)&lt;/text>
&lt;path d="M250,135 L80,135" fill="none" stroke="#3c8c54" stroke-width="1.8" marker-end="url(#lfs)"/>
&lt;text x="165" y="129" text-anchor="middle" font-family="sans-serif" font-size="9.5" font-weight="700" fill="#3c8c54">HTTP 207 (receipt) ~ms&lt;/text>
&lt;text x="165" y="160" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#999">— the client has already moved on —&lt;/text>
&lt;path d="M560,190 L700,190" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="630" y="184" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">③ Worker pulls ref&lt;/text>
&lt;path d="M700,215 L420,215" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="560" y="209" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">④ reads event from S3&lt;/text>
&lt;path d="M700,250 L700,265 L660,265" fill="none" stroke="#4a6fa5" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="700" y="244" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">⑤ buffer + batch flush → INSERT CH&lt;/text>
&lt;rect x="40" y="120" width="220" height="22" rx="4" fill="#dceede" stroke="#3c8c54" stroke-width="1" opacity="0.5"/>
&lt;rect x="610" y="178" width="160" height="100" rx="4" fill="#dde6f3" stroke="#4a6fa5" stroke-width="1" opacity="0.4"/>
&lt;text x="690" y="294" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#999">async, off the client path&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The mathematical point is the &lt;strong>early ACK&lt;/strong>. The latency the client perceives when sending traces is:&lt;/p>
$$ t_{\text{client}} = t_{\text{S3 write}} + t_{\text{Redis enqueue}} \approx 10\text{–}40\,\text{ms} $$
&lt;p>whereas the real cost of persisting, reading S3, transforming, merging against the previous version, inserting into ClickHouse, letting the background merges compact, happens &lt;strong>off that path&lt;/strong>, in the Worker, and can take hundreds of ms or seconds without the client caring. The decoupling turns a system whose throughput would be limited by ClickHouse speed into one limited by Redis speed. And Redis, on modest hardware, sustains on the order of &lt;strong>50,000 operations/second&lt;/strong>.&lt;/p>
&lt;p>This has an important sizing consequence. If your inference load generates $E$ events/second (a chat with RAG + 2 tool calls easily produces 6-10 spans = events per request), the Web container absorbs them while $E \ll 50{,}000$. The Worker, by contrast, scales with the cost of &lt;em>processing&lt;/em>: that is the component to watch and replicate, and the first knob of the post.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Honest scepticism.&lt;/strong> The early ACK has a flip side: between the HTTP 207 and persistence in ClickHouse there is a &lt;strong>potential loss window&lt;/strong>. If the event is in S3 and the reference in Redis, and Redis goes down without persistence (AOF/RDB) before the Worker processes it, the reference is lost; the data is still in S3 but nobody claims it any more. More subtly: the Worker buffers writes to ClickHouse in memory and flushes them in batches; a Worker crash with a full buffer loses that batch. There is a &lt;a href="https://github.com/langfuse/langfuse/issues/13468">reported bug&lt;/a> where the &lt;code>ClickhouseWriter&lt;/code> discards rows after exhausting flush retries &lt;strong>with no dead-letter queue&lt;/strong>. For observability this is usually tolerable (losing 0.01 % of the traces breaks nothing). For &lt;em>regulatory auditing&lt;/em>, where the trace is evidence, it is not, and Langfuse is better treated as &amp;ldquo;best-effort&amp;rdquo; and not as a ledger. We will come back to this at the close.&lt;/p>
&lt;/blockquote>
&lt;h2 id="interaction-with-the-rest-of-the-stack-langfuse-in-the-example-4h100-cluster">Interaction with the rest of the stack: Langfuse in the example 4×H100 cluster&lt;/h2>
&lt;p>Langfuse does not live in isolation. In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a> it occupies the LLM-aware observability layer, and it relates to almost every other one. On the generic reference cluster we use throughout the blog, &lt;strong>4×H100 SXM 80 GB (320 GB aggregate VRAM), NVLink, 640 GB of system RAM, NVMe-oF, 25/100 GbE network&lt;/strong>, the telemetry flow looks like this:&lt;/p>
&lt;div class="diagram" style="max-width:840px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 840 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Interaction of Langfuse with the inference stack on a 4xH100 cluster">
&lt;defs>&lt;marker id="lfx" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="420" y="24" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="currentColor">Data plane vs telemetry plane · 4×H100 SXM cluster&lt;/text>
&lt;!-- Data plane -->
&lt;rect x="24" y="44" width="430" height="356" rx="10" fill="none" stroke="#4a6fa5" stroke-width="1.4" stroke-dasharray="6 3"/>
&lt;text x="239" y="62" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#4a6fa5">Data plane (serves tokens · hot path)&lt;/text>
&lt;rect x="50" y="78" width="170" height="50" rx="7" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="135" y="98" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">App / Agent&lt;/text>
&lt;text x="135" y="115" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">Langfuse / OTel SDK&lt;/text>
&lt;rect x="50" y="150" width="170" height="50" rx="7" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="135" y="170" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">L7 Gateway&lt;/text>
&lt;text x="135" y="187" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">router / LiteLLM&lt;/text>
&lt;rect x="50" y="222" width="170" height="58" rx="7" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>
&lt;text x="135" y="244" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">vLLM (TP=4)&lt;/text>
&lt;text x="135" y="261" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">general LLM · H100×4&lt;/text>
&lt;rect x="252" y="222" width="180" height="58" rx="7" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>
&lt;text x="342" y="240" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="700" fill="#222">Embeddings + Reranker&lt;/text>
&lt;text x="342" y="256" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">MIG slices&lt;/text>
&lt;text x="342" y="270" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">+ vector store&lt;/text>
&lt;rect x="50" y="306" width="382" height="42" rx="7" fill="#f7efda" stroke="#c79a32" stroke-width="1.4"/>
&lt;text x="241" y="332" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#333">Guardrails · semantic cache · tool services&lt;/text>
&lt;!-- OTel Collector centre -->
&lt;rect x="486" y="150" width="150" height="74" rx="9" fill="#f0e6d2" stroke="#b58a2e" stroke-width="1.8"/>
&lt;text x="561" y="178" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">OTel Collector&lt;/text>
&lt;text x="561" y="195" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">batch · tail-sampling&lt;/text>
&lt;text x="561" y="209" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">attributes (tenant_id)&lt;/text>
&lt;!-- telemetry plane -->
&lt;rect x="660" y="44" width="160" height="356" rx="10" fill="none" stroke="#7a5aa5" stroke-width="1.4" stroke-dasharray="6 3"/>
&lt;text x="740" y="62" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#7a5aa5">Telemetry (cold)&lt;/text>
&lt;rect x="676" y="80" width="128" height="120" rx="9" fill="#e6ddf3" stroke="#7a5aa5" stroke-width="1.8"/>
&lt;text x="740" y="104" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">Langfuse&lt;/text>
&lt;text x="740" y="124" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">Web + Worker&lt;/text>
&lt;text x="740" y="140" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">CH · PG · Redis&lt;/text>
&lt;text x="740" y="156" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">MinIO&lt;/text>
&lt;text x="740" y="178" text-anchor="middle" font-family="sans-serif" font-size="8.5" font-style="italic" fill="#777">dedicated CPU node&lt;/text>
&lt;text x="740" y="191" text-anchor="middle" font-family="sans-serif" font-size="8.5" font-style="italic" fill="#777">(off the GPUs)&lt;/text>
&lt;rect x="676" y="220" width="128" height="50" rx="8" fill="#dde6f3" stroke="#4a6fa5" stroke-width="1.3"/>
&lt;text x="740" y="242" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="700" fill="#222">Tempo&lt;/text>
&lt;text x="740" y="258" text-anchor="middle" font-family="sans-serif" font-size="8.5" fill="#444">infra spans&lt;/text>
&lt;rect x="676" y="288" width="128" height="50" rx="8" fill="#dde6f3" stroke="#4a6fa5" stroke-width="1.3"/>
&lt;text x="740" y="310" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="700" fill="#222">Prometheus&lt;/text>
&lt;text x="740" y="326" text-anchor="middle" font-family="sans-serif" font-size="8.5" fill="#444">DCGM · vLLM&lt;/text>
&lt;!-- data arrows -->
&lt;path d="M135,128 L135,150" fill="none" stroke="#4a6fa5" stroke-width="1.6" marker-end="url(#lfx)"/>
&lt;path d="M135,200 L135,222" fill="none" stroke="#4a6fa5" stroke-width="1.6" marker-end="url(#lfx)"/>
&lt;path d="M220,251 L252,251" fill="none" stroke="#4a6fa5" stroke-width="1.4" marker-end="url(#lfx)"/>
&lt;!-- spans to collector -->
&lt;path d="M220,160 C360,150 420,180 486,180" fill="none" stroke="#b58a2e" stroke-width="1.5" marker-end="url(#lfx)" stroke-dasharray="4 2"/>
&lt;text x="350" y="146" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#b58a2e">gen_ai.* spans&lt;/text>
&lt;path d="M220,250 C400,300 430,210 486,200" fill="none" stroke="#b58a2e" stroke-width="1.3" marker-end="url(#lfx)" stroke-dasharray="4 2"/>
&lt;!-- collector to backends -->
&lt;path d="M636,170 L676,130" fill="none" stroke="#7a5aa5" stroke-width="1.6" marker-end="url(#lfx)"/>
&lt;text x="660" y="138" text-anchor="middle" font-family="sans-serif" font-size="8.5" fill="#7a5aa5">LLM traces&lt;/text>
&lt;path d="M636,200 L676,240" fill="none" stroke="#4a6fa5" stroke-width="1.4" marker-end="url(#lfx)"/>
&lt;path d="M636,210 L676,305" fill="none" stroke="#4a6fa5" stroke-width="1.2" marker-end="url(#lfx)"/>
&lt;/svg>
&lt;/div>
&lt;p>Three ideas from this topology:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Langfuse receives from the OTel Collector, not from the application directly&lt;/strong> (in the recommended pattern). The app SDK or vLLM emit spans with the &lt;code>gen_ai.*&lt;/code> semantic conventions; the Collector does &lt;code>batch&lt;/code>, &lt;code>tail-sampling&lt;/code> (preserving 100 % of errors and high latencies, sampling the rest) and enriches with its own attributes (&lt;code>tenant_id&lt;/code>, &lt;code>priority_tier&lt;/code>); and it &lt;em>distributes&lt;/em>: LLM traces go to Langfuse, infrastructure spans to Tempo, metrics (GPU DCGM, vLLM metrics) to Prometheus. Langfuse is &lt;strong>one more exporter&lt;/strong>, not the only destination. This is covered in detail in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">the OTel tracing post&lt;/a>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Langfuse runs off the GPUs.&lt;/strong> It is a consumer of CPU, RAM, disk and network (ClickHouse wants memory, MinIO wants disk, Redis wants CPU for networking) but &lt;strong>it does not touch VRAM&lt;/strong>. On the 4×H100 cluster, Langfuse lives on a CPU node (or on the GPU nodes but with &lt;code>nodeSelector&lt;/code>/&lt;code>taints&lt;/code> keeping it away from the vLLM pods). Mixing ClickHouse with vLLM on the same node without resource limits is asking for an ingestion spike to steal memory bandwidth from inference. Isolation by design.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>The telemetry route is &amp;ldquo;cold&amp;rdquo; and the data route is &amp;ldquo;hot&amp;rdquo;.&lt;/strong> The data plane (left) serves tokens with a millisecond latency budget; the telemetry plane (right) tolerates seconds. The early ingestion ACK is what keeps these two clocks apart: the app does not wait for Langfuse to file anything before returning the response to the user.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="the-10-backend-knobs-that-move-the-needle-most">The 10 backend knobs that move the needle most&lt;/h2>
&lt;p>These are, in approximate order of impact/frequency, the settings that decide whether your self-hosted Langfuse ingests 50 events/s or 5,000, and whether your disk grows sustainably or explodes in three weeks. They are all environment variables or config injected into the &lt;strong>Web and Worker&lt;/strong> containers (except the ClickHouse ones, which go in its server-side config). The canonical detail is in the &lt;a href="https://langfuse.com/self-hosting/configuration/scaling">Langfuse scaling doc&lt;/a>.&lt;/p>
&lt;h3 id="knob-1--scale-the-worker-by-load-the-first-lever-always">Knob 1 — Scale the Worker by load (the first lever, always)&lt;/h3>
&lt;p>The Worker is the component that saturates first, because it does the expensive work: reading S3, transforming, merging, inserting into ClickHouse. The Langfuse operational rule is simple: &lt;strong>a 2-CPU Worker container above 50 % CPU use is saturated&lt;/strong>; add replicas. Better than CPU, the Worker publishes via statsd the metric &lt;code>langfuse.queue.ingestion.length&lt;/code> (ingestion queue length), which is the direct signal for autoscaling: if the queue grows without draining, there are not enough Workers.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># The ideal autoscaler watches queue depth, not just CPU.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># (KEDA ScaledObject over the statsd metric → Prometheus)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">langfuse_queue_ingestion_length&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;10000&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># if the queue goes past 10k refs, scale&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In AWS deployments there is &lt;code>ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING=true&lt;/code> to push these metrics to CloudWatch. On-premise, the path is statsd → Prometheus → KEDA, slotting into the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling on Kubernetes with KEDA&lt;/a> we already covered for vLLM. &lt;strong>Always start here&lt;/strong>: most &amp;ldquo;Langfuse is slow&amp;rdquo; problems are simply insufficient Workers, not fine tuning.&lt;/p>
&lt;h3 id="knob-2--separate-the-ingestion-deployment-from-the-ui-one">Knob 2 — Separate the ingestion deployment from the UI one&lt;/h3>
&lt;p>When ingestion is heavily loaded, UI queries and the public API become slow because they share the same Web container. The solution is to &lt;strong>split langfuse-web into two identical deployments&lt;/strong> and route by path: everything under &lt;code>/api/public/ingestion*&lt;/code>, &lt;code>/api/public/media*&lt;/code> and &lt;code>/api/public/otel*&lt;/code> goes to the ingestion deployment; the rest (UI, read API) to the interface one.&lt;/p>
&lt;pre tabindex="0">&lt;code># Ingress / gateway rule
location ~ ^/api/public/(ingestion|media|otel) {
proxy_pass http://langfuse-web-ingest; # replicas dedicated to writing
}
location / {
proxy_pass http://langfuse-web-ui; # replicas dedicated to reading
}
&lt;/code>&lt;/pre>&lt;p>It is the same idea as the read/write separation of any system with mixed loads: a storm of writes should not starve whoever is trying to &lt;em>look at&lt;/em> the dashboard right in the middle of the incident, which is precisely when they need it most.&lt;/p>
&lt;h3 id="knob-3--batching-writes-to-clickhouse-interval--batch-size">Knob 3 — Batching writes to ClickHouse (interval + batch size)&lt;/h3>
&lt;p>ClickHouse hates small, frequent inserts: every &lt;code>INSERT&lt;/code> creates a &lt;em>part&lt;/em> on disk that then has to be merged, and thousands of tiny inserts generate thousands of parts and a storm of background merges that saturates the disk. The defence is to &lt;strong>accumulate in an in-memory buffer in the Worker and flush in large batches&lt;/strong>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Worker: fewer flushes, larger batches → fewer parts, fewer merges&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">1000&lt;/span> &lt;span class="c1"># raise e.g. to 2000-5000&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">10000&lt;/span> &lt;span class="c1"># raise if there is throughput&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Raising the interval and the batch size &lt;strong>reduces the flush frequency&lt;/strong> and improves sustained throughput. The trade-off is direct and has to be understood: larger, less frequent batches mean &lt;strong>more data in the Worker&amp;rsquo;s volatile buffer&lt;/strong>, that is, a bigger loss window if the Worker goes down (a knob coupled to the scepticism at the close). Langfuse also uses ClickHouse &lt;code>async_insert&lt;/code>, which accumulates server-side before confirming; it adds another layer of buffering to keep in mind.&lt;/p>
&lt;h3 id="knob-4--skip-the-clickhouse-read-before-write-in-ingestion">Knob 4 — Skip the ClickHouse read-before-write in ingestion&lt;/h3>
&lt;p>By default, when ingesting an event the Worker &lt;strong>reads the existing event from ClickHouse and merges it&lt;/strong> with the incoming one (necessary when legacy SDKs send partial events: a &lt;code>start&lt;/code>, then an &lt;code>end&lt;/code>, then an &lt;code>update&lt;/code> of the same observation). That per-event read loads ClickHouse on the write path and limits total throughput.&lt;/p>
&lt;p>If your projects are not migrated from an old version, because the full history already lives in S3, you can &lt;strong>disable that read&lt;/strong>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Date earlier than the creation of your first project&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_SKIP_INGESTION_CLICKHOUSE_READ_MIN_PROJECT_CREATE_DATE&lt;/span>&lt;span class="o">=&lt;/span>2025-01-01
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With modern Langfuse SDKs or with ingestion via OpenTelemetry, this does not affect you negatively and removes one read per event. A warning from the doc itself: if you combine this with aggressive deletion (lifecycle) rules in S3 plus late event updates, you can generate duplicates in the history. Know that before enabling it.&lt;/p>
&lt;h3 id="knob-5--write-concurrency-to-s3blob-storage">Knob 5 — Write concurrency to S3/Blob storage&lt;/h3>
&lt;p>In high-throughput scenarios, the S3 client can &lt;strong>exhaust its sockets&lt;/strong> and start queueing and throttling writes. The symptom is unmistakable in the logs of the Web container that handles ingestion:&lt;/p>
&lt;pre tabindex="0">&lt;code>@smithy/node-http-handler:WARN - socket usage at capacity=150
and 387 additional requests are enqueued.
&lt;/code>&lt;/pre>&lt;p>…accompanied by a rise in memory in that container (queued requests pile up in RAM). The cure is to raise the concurrent write limit from its default of 50:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_S3_CONCURRENT_WRITES&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">100&lt;/span> &lt;span class="c1"># raise gradually from 50&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Each additional socket has a small memory cost, so the official advice is to raise it &lt;strong>gradually&lt;/strong> while observing the behaviour, not to jump to 1000 in one go.&lt;/p>
&lt;h3 id="knob-6--redis-queue-sharding--concurrency-per-shard">Knob 6 — Redis queue sharding + concurrency &lt;em>per shard&lt;/em>&lt;/h3>
&lt;p>If Redis goes past 90 % CPU, first the obvious: an instance with &lt;strong>at least 4 CPUs&lt;/strong> (so Redis spreads networking and background tasks across different cores) and &lt;strong>Redis Cluster mode&lt;/strong> enabled. If CPU still does not come down, you can &lt;strong>shard the queues&lt;/strong> Langfuse uses:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Advanced: only if Redis is genuinely drowning and you already did the above&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_INGESTION_QUEUE_SHARD_COUNT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">6&lt;/span> &lt;span class="c1"># ~2-3× the number of Redis cluster shards&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">6&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Concurrency counts PER SHARD; target ~20 per worker&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_INGESTION_QUEUE_PROCESSING_CONCURRENCY&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">3&lt;/span> &lt;span class="c1"># 6 shards × ~3 ≈ 18&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_TRACE_UPSERT_WORKER_CONCURRENCY&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">3&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two traps the doc underlines and that are worth tattooing: &lt;strong>once you shard, do not reduce the number of shards&lt;/strong> (it breaks the distribution); and concurrency counts &lt;strong>per shard&lt;/strong>, not globally, so if you have 10 shards and want concurrency 20 per worker, set &lt;code>2&lt;/code>, not &lt;code>20&lt;/code>. It is an advanced knob: most on-premise deployments never need it.&lt;/p>
&lt;h3 id="knob-7--the-final-modifier-for-otel-only-projects">Knob 7 — The &lt;code>FINAL&lt;/code> modifier for OTel-only projects&lt;/h3>
&lt;p>Langfuse stores observations in a ClickHouse &lt;code>ReplacingMergeTree&lt;/code> and, by default, adds the &lt;code>FINAL&lt;/code> modifier to API queries so the last version of each row wins at read time. &lt;code>FINAL&lt;/code> is necessary when ingestion produces several versions of the same observation (legacy SDKs with their &lt;code>start&lt;/code>/&lt;code>end&lt;/code>/&lt;code>update&lt;/code> events), but &lt;strong>it adds merge work to every read and slows it down&lt;/strong>.&lt;/p>
&lt;p>Projects that ingest &lt;strong>exclusively over OpenTelemetry&lt;/strong> write each observation as a single immutable row, so &lt;code>FINAL&lt;/code> is redundant for them:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Recommended in mixed deployments: per-project, flagged in Redis with a 24h TTL&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_SKIP_FINAL_FOR_OTEL_PROJECTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Only if ALL projects are OTel-only: global, no Redis lookup&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_API_CLICKHOUSE_DISABLE_OBSERVATIONS_FINAL&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Since on the example cluster the instrumentation is 100 % OTel (&lt;code>gen_ai.*&lt;/code> via Collector), this knob is &lt;strong>free money in dashboard read latency&lt;/strong>. Careful with the global version: do not enable it if some project is still using legacy ingestion, or reads may return duplicate or stale rows.&lt;/p>
&lt;h3 id="knob-8--separate-analytical-reads-from-the-write-path-compute-compute">Knob 8 — Separate analytical reads from the write path (compute-compute)&lt;/h3>
&lt;p>Heavy dashboard queries (percentiles over millions of spans) compete with ingestion inserts and with background merges on the &lt;em>same&lt;/em> ClickHouse. If your deployment supports &lt;strong>compute-compute separation&lt;/strong> (ClickHouse Cloud or BYOC), you can route reads to a read-only compute group:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">CLICKHOUSE_URL&lt;/span>&lt;span class="o">=&lt;/span>http://clickhouse-primary:8123 &lt;span class="c1"># writes, migrations, ingestion&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">CLICKHOUSE_READ_ONLY_URL&lt;/span>&lt;span class="o">=&lt;/span>http://clickhouse-reader:8123 &lt;span class="c1"># UI + public API reads&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Critical nuance for on-premise&lt;/strong>, and here it is time to be sceptical about the usefulness of this knob in our context: on a &lt;strong>single-node&lt;/strong> ClickHouse or on a self-managed cluster without compute separation, this variable &lt;strong>adds nothing&lt;/strong>, because the read endpoint would be the same as the write one. It is a knob for cloud architectures with storage separated from compute. On an on-premise 4×H100 cluster with ClickHouse on one node, the real alternative is to &lt;strong>scale ClickHouse vertically&lt;/strong> (the doc recommends ≥16 GiB of RAM for large deployments; ClickHouse scales vertically well) and to make sure &lt;strong>every query filters by &lt;code>projectId&lt;/code> and time&lt;/strong>, which is how the tables are indexed. Without a time filter, even the fattest ClickHouse suffers.&lt;/p>
&lt;h3 id="knob-9--data-retention-ttl-in-clickhouse--lifecycle-in-s3">Knob 9 — Data retention: TTL in ClickHouse + lifecycle in S3&lt;/h3>
&lt;p>Disk is the cost that grows on its own. LLM traces carry whole inputs and outputs (sometimes prompts of tens of KB), and ClickHouse also accumulates its own system tables. The first-order lever is a &lt;strong>retention policy&lt;/strong> that nightly deletes traces, observations, scores and media older than N days, coordinating ClickHouse and blob storage. Where the retention feature is not available, it is done by hand:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- ClickHouse: TTL over the tracing tables
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">traces&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">toDateTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">timestamp&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">90&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">observations&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">toDateTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">start_time&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">90&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">scores&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">toDateTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">timestamp&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">90&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">event_log&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">toDateTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">timestamp&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">30&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;pre tabindex="0">&lt;code># S3/MinIO: lifecycle rule, e.g. 30 days for the raw event bucket
# WATCH OUT! Do NOT apply retention to the MEDIA bucket:
# - it breaks the files referenced in traces
# - it breaks future uploads (state is tracked by hash in Postgres)
&lt;/code>&lt;/pre>&lt;p>Two operational parameters that avoid nasty surprises on large deletions:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">600000&lt;/span> &lt;span class="c1"># default 10 min; raise it if deletions time out&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ClickHouse 25.7+: less mutation pressure on bulk deletions&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">CLICKHOUSE_LIGHTWEIGHT_DELETE_MODE&lt;/span>&lt;span class="o">=&lt;/span>lightweight_update
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">CLICKHOUSE_USE_LIGHTWEIGHT_UPDATE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The mental rule: &lt;strong>short retention for raw events&lt;/strong> (S3, 30 days is usually enough, they are recoverable/recomputable), &lt;strong>retention by business value for the ClickHouse tables&lt;/strong> (90 days, 180, whatever compliance asks for), and &lt;strong>never touch the media bucket with blind lifecycle rules&lt;/strong>.&lt;/p>
&lt;h3 id="knob-10--hygiene-of-the-clickhouse-system-log-tables-the-silent-disk-killer">Knob 10 — Hygiene of the ClickHouse &lt;em>system log tables&lt;/em> (the silent disk killer)&lt;/h3>
&lt;p>This is the knob nobody configures and that fills the disk without showing up in any Langfuse metric, because &lt;strong>it is not Langfuse data&lt;/strong>: they are the system tables of ClickHouse itself (&lt;code>trace_log&lt;/code>, &lt;code>text_log&lt;/code>, &lt;code>opentelemetry_span_log&lt;/code>, &lt;code>asynchronous_metric_log&lt;/code>, &lt;code>metric_log&lt;/code>, &lt;code>latency_log&lt;/code>). By default &lt;strong>they have no TTL&lt;/strong>, and the query profiler writes to &lt;code>system.trace_log&lt;/code> continuously. On a ClickHouse with traffic, these tables can &lt;strong>dominate disk usage&lt;/strong> while you look for the problem in your traces. Langfuse does not read from them, so they can be trimmed without fear. Two options:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-xml" data-lang="xml">&lt;span class="line">&lt;span class="cl">&lt;span class="c">&amp;lt;!-- Option A — disable the ones Langfuse never reads
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c"> (file in /etc/clickhouse-server/config.d/) --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nt">&amp;lt;clickhouse&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;trace_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;text_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;opentelemetry_span_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;asynchronous_metric_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;metric_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;latency_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nt">&amp;lt;/clickhouse&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c">&amp;lt;!-- Keep query_log, part_log and error_log: useful for debugging and small --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- Option B — aggressive TTL + turn off the profiler, if you want to keep them for debugging
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- (in config: query_profiler_real_time_period_ns = 0)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">max_table_size_to_drop&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">TRUNCATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">system&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">trace_log&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">system&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">trace_log&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">event_date&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">7&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- repeat for each log table to cap
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To identify which table is eating the disk, the golden query:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">table&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">formatReadableSize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bytes&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">size&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">rows&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">rows&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">system&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">parts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">active&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">table&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bytes&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you take only one knob from this post to your first real deployment, make it this one: the difference between a ClickHouse that grows 2 GB/day of useful data and one that grows 20 GB/day of system logs nobody looks at.&lt;/p>
&lt;h3 id="summary-table-of-the-10-knobs">Summary table of the 10 knobs&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>Variable / action&lt;/th>
&lt;th>When&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>Scale Worker&lt;/td>
&lt;td>replicas by CPU&amp;gt;50 % / &lt;code>langfuse.queue.ingestion.length&lt;/code>&lt;/td>
&lt;td>always, first&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>Separate ingestion/UI&lt;/td>
&lt;td>route &lt;code>/ingestion*&lt;/code>,&lt;code>/media*&lt;/code>,&lt;code>/otel*&lt;/code> to a dedicated replica&lt;/td>
&lt;td>slow UI under load&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>Batching to ClickHouse&lt;/td>
&lt;td>&lt;code>LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS&lt;/code> / &lt;code>_BATCH_SIZE&lt;/code>&lt;/td>
&lt;td>high throughput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>Skip CH read-before-write&lt;/td>
&lt;td>&lt;code>LANGFUSE_SKIP_INGESTION_CLICKHOUSE_READ_MIN_PROJECT_CREATE_DATE&lt;/code>&lt;/td>
&lt;td>non-migrated projects&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>S3 concurrency&lt;/td>
&lt;td>&lt;code>LANGFUSE_S3_CONCURRENT_WRITES&lt;/code> (def. 50)&lt;/td>
&lt;td>&amp;ldquo;socket usage at capacity&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>Redis queue sharding&lt;/td>
&lt;td>&lt;code>LANGFUSE_*_QUEUE_SHARD_COUNT&lt;/code> + &lt;code>*_CONCURRENCY&lt;/code> (per shard)&lt;/td>
&lt;td>Redis CPU &amp;gt;90 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>Remove &lt;code>FINAL&lt;/code> (OTel)&lt;/td>
&lt;td>&lt;code>LANGFUSE_SKIP_FINAL_FOR_OTEL_PROJECTS=true&lt;/code>&lt;/td>
&lt;td>100 % OTel instrumentation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>CH read/write split&lt;/td>
&lt;td>&lt;code>CLICKHOUSE_READ_ONLY_URL&lt;/code> (cloud/BYOC only)&lt;/td>
&lt;td>compute-compute available&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>Retention + TTL&lt;/td>
&lt;td>TTL in CH + S3 lifecycle + &lt;code>LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS&lt;/code>&lt;/td>
&lt;td>always (disk cost)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>CH system log hygiene&lt;/td>
&lt;td>&lt;code>&amp;lt;trace_log remove=&amp;quot;1&amp;quot;/&amp;gt;&lt;/code> or aggressive TTL&lt;/td>
&lt;td>always (hidden disk)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-to-maximise-langfuse-on-the-example-4h100-cluster">How to maximise Langfuse on the example 4×H100 cluster&lt;/h2>
&lt;p>With the architecture and the knobs clear, this is a concrete sizing to get the most out of Langfuse on the generic reference cluster (&lt;strong>4×H100 SXM, 320 GB VRAM, 640 GB RAM, NVMe-oF, 25/100 GbE&lt;/strong>), without stealing a single GB of VRAM from inference.&lt;/p>
&lt;h3 id="component-distribution">Component distribution&lt;/h3>
&lt;p>Langfuse is &lt;strong>100 % CPU/RAM/disk/network load&lt;/strong>, so its natural place is &lt;strong>off the GPU nodes&lt;/strong> or, if it cohabits, with &lt;code>taints&lt;/code>/&lt;code>nodeSelector&lt;/code> confining it away from the vLLM pods. Suggested distribution:&lt;/p>
&lt;pre tabindex="0">&lt;code>nodo-cpu-01 (control + observability, no GPU)
├── langfuse-web-ingest ×3 (2 CPU / 4 GiB each) ← ingestion, scales with load
├── langfuse-web-ui ×2 (2 CPU / 4 GiB each) ← dashboard/read API
├── langfuse-worker ×4 (2 CPU / 4 GiB each) ← the one that scales most
├── redis/valkey ×1 (4 CPU / 4 GiB, cluster mode)
└── postgres ×1 (2 CPU / 8 GiB, replica for HA)
nodo-storage-01 (heavy state, local NVMe)
├── clickhouse ×1 (8 CPU / 32 GiB / NVMe) ← ≥16 GiB is the minimum; 32 is comfortable
└── minio (S3) ×1 (4 CPU / 8 GiB / HDD+NVMe cache)
nodo-gpu-01..02 (4×H100 SXM each) → inference ONLY
└── vLLM, embeddings, reranker, guardrails (emit spans, do not host Langfuse)
&lt;/code>&lt;/pre>&lt;h3 id="sizing-by-real-load">Sizing by real load&lt;/h3>
&lt;p>Let us put numbers on an example load. Suppose the cluster serving &lt;strong>300 requests/second&lt;/strong> of chat-with-RAG, where each request generates on the order of &lt;strong>8 spans&lt;/strong> (request, retrieval, rerank, 2× tool, guardrail in, llm, guardrail out):&lt;/p>
$$ E = 300\,\tfrac{\text{req}}{\text{s}} \times 8\,\tfrac{\text{spans}}{\text{req}} = 2,400\ \text{events/s} $$
&lt;p>Against the Redis ceiling (~50,000 ops/s), $E = 2{,}400$ leaves the reception counter at &lt;strong>~5 % of its capacity&lt;/strong>: enormous headroom. The component to watch is the Worker. With a target of ~20 concurrency per Worker and batches of 10,000 events every ~1-2 s, 4 Workers drain 2,400 ev/s with margin; the &lt;code>langfuse.queue.ingestion.length&lt;/code> metric should stay flat near zero. If it grows, knob 1 (more Workers) is the answer before any fine tuning.&lt;/p>
&lt;p>&lt;strong>Tail-sampling is the multiplier that changes the economics.&lt;/strong> If the Collector preserves 100 % of errors/high latencies but samples normal traffic at, say, 10 %, the 2,400 ev/s you &lt;em>store&lt;/em> in ClickHouse drop to ~240-300 effective ev/s without losing the signal that matters. The rule: &lt;strong>sample in the Collector, not in Langfuse&lt;/strong>. Langfuse should receive, already filtered, what deserves to be persisted. This is developed in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">the OTel tracing post&lt;/a>; here it is enough to note that upstream sampling is, in effect, the knob 0 that multiplies all the others.&lt;/p>
&lt;h3 id="storage-estimate">Storage estimate&lt;/h3>
&lt;p>An LLM observation with full input+output weighs, compressed in ClickHouse, on the order of &lt;strong>1-3 KB&lt;/strong> (ClickHouse compresses text very well, 5-10×). With sampling at 10 % over 2,400 ev/s:&lt;/p>
$$ 240\,\tfrac{\text{ev}}{\text{s}} \times 2\,\text{KB} \times 86,400\,\tfrac{\text{s}}{\text{day}} \approx 41\ \text{GB/day (raw)} \;\xrightarrow{\text{compression}}\; \sim 5\text{–}8\ \text{GB/day in CH} $$
&lt;p>At 90 days of retention (knob 9), the permanent archive settles at around &lt;strong>500-700 GB in ClickHouse&lt;/strong>, comfortable on the NVMe of the storage node, plus the raw events in MinIO with a 30-day lifecycle. &lt;strong>Without&lt;/strong> the system log hygiene (knob 10), add easily as much again in rubbish nobody queries. The two disk knobs together are the difference between planning storage once a year and fighting a full disk every month.&lt;/p>
&lt;h3 id="maximum-use-checklist">&amp;ldquo;Maximum use&amp;rdquo; checklist&lt;/h3>
&lt;ol>
&lt;li>&lt;strong>Sampling in the Collector&lt;/strong> (tail: 100 % errors + N % normal), before touching anything in Langfuse.&lt;/li>
&lt;li>&lt;strong>Workers scaled by queue length&lt;/strong> via KEDA (knob 1), not fixed.&lt;/li>
&lt;li>&lt;strong>Ingestion separated from UI&lt;/strong> (knob 2) so the dashboard responds during incidents.&lt;/li>
&lt;li>&lt;strong>&lt;code>SKIP_FINAL_FOR_OTEL_PROJECTS&lt;/code>&lt;/strong> enabled (knob 7) because the instrumentation is 100 % OTel.&lt;/li>
&lt;li>&lt;strong>Generous CH batching&lt;/strong> (knob 3) tuned to the throughput, accepting the loss window.&lt;/li>
&lt;li>&lt;strong>Retention + TTL + system log hygiene&lt;/strong> (knobs 9 and 10) configured on day 1, not when the disk screams.&lt;/li>
&lt;li>&lt;strong>ClickHouse with ≥16 GiB and every query filtering by &lt;code>projectId&lt;/code>+time&lt;/strong> (knob 8 in its on-premise version: vertical scaling).&lt;/li>
&lt;li>&lt;strong>Langfuse isolated from the GPUs&lt;/strong> by &lt;code>taints&lt;/code>/&lt;code>nodeSelector&lt;/code>: not a MB of VRAM, no memory bandwidth contention with vLLM.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;Langfuse guarantees me full traceability.&amp;rdquo;&lt;/strong> No: the design is &lt;strong>high-performance best-effort&lt;/strong>, not a ledger. Between the HTTP 207 and the row in ClickHouse there are volatile buffers (Redis without hard persistence, the Worker&amp;rsquo;s in-memory buffer, ClickHouse&amp;rsquo;s server-side &lt;code>async_insert&lt;/code>). There is a &lt;a href="https://github.com/langfuse/langfuse/issues/13468">known bug&lt;/a> where the writer discards rows with no dead-letter queue after exhausting retries. For operational observability, losing 0.01 % of spans is irrelevant. For &lt;strong>ENS/EU AI Act audit evidence&lt;/strong>, where the trace &lt;em>is&lt;/em> the proof, Langfuse should not be the only record; the regulatory audit log needs durability guarantees this pipeline does not promise. A distinction covered in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">the technical controls for ENS/42001/EU AI Act&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Raising ClickHouse batching &amp;ldquo;to go faster&amp;rdquo; and nothing else.&lt;/strong> Knob 3 improves throughput at the cost of enlarging the loss window and the latency of data appearing in the dashboard. Batches of 50,000 every 10 s perform wonderfully… until the Worker restarts with 50,000 events in the buffer. Tune with awareness of the trade-off, not by blind maximisation.&lt;/p>
&lt;p>&lt;strong>Putting ClickHouse on the same node as vLLM with no limits.&lt;/strong> ClickHouse is voracious with memory bandwidth during merges. Sharing a node with vLLM without &lt;code>resources.limits&lt;/code> or NUMA isolation means an ingestion spike can degrade inference TTFT, exactly the original sin this whole architecture wanted to avoid. Isolate.&lt;/p>
&lt;p>&lt;strong>Forgetting the time filter in your own queries.&lt;/strong> The ClickHouse tables are indexed by &lt;code>projectId&lt;/code> and time. A custom dashboard or an API query without a time filter scans the whole history and takes performance down for everyone. It is not Langfuse &amp;ldquo;being slow&amp;rdquo;: it is a badly written query.&lt;/p>
&lt;p>&lt;strong>Applying lifecycle to the media bucket.&lt;/strong> It breaks the files referenced in traces and blocks future uploads (state is tracked by hash in Postgres). The media bucket is managed &lt;strong>only&lt;/strong> with the Langfuse retention feature, never with blind S3 rules.&lt;/p>
&lt;p>&lt;strong>Treating queue sharding as a routine optimisation.&lt;/strong> It is an advanced knob for a genuinely drowning Redis, irreversible (do not reduce shards) and with &lt;em>per-shard&lt;/em> concurrency semantics that are easy to misread. In the vast majority of on-premise deployments it is not needed; if you enable it &amp;ldquo;just in case&amp;rdquo;, you complicate your life without gaining anything.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Langfuse v3 solved the structural problem of LLM observability, that the observer should not suffocate the observed, by moving from a monolith on Postgres to a six-service sorting centre with asynchronous ingestion. That design is what allows a cluster serving thousands of tokens per second to be instrumented end to end without the app ever waiting for a trace to be filed. But the design is a necessary condition, not a sufficient one: it performs if the right levers are tuned. Of the ten knobs, three decide almost everything in a typical on-premise deployment, &lt;strong>scaling Workers by queue length (1), retention + TTL (9), and system log hygiene (10)&lt;/strong>; the rest are refinements that show up when the load bites. And above them all lives knob 0, which is not a Langfuse one: &lt;strong>sampling in the Collector&lt;/strong>, which decides how much reaches the pipeline before any internal setting matters. Maximising Langfuse on the 4×H100 cluster is not about squeezing its peak throughput: it is about putting it off the GPUs, feeding it already-sampled traffic, sizing the Worker by the queue, and configuring retention on day one, so that the tool that came to tell the story does not end up being the chapter about the incident.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the &lt;code>SDK → Collector → backend&lt;/code> pipeline that feeds Langfuse. There Langfuse is treated as a destination; here it is opened up. The two-layer sampling of that post is the knob 0 that multiplies the ten in this one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> — the prompt management layer that lives in Postgres (not in ClickHouse). The &lt;code>prompt_id@version&lt;/code> that post propagates as a span attribute lands in the tracing tables described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> — the Langfuse datasets and evaluators rest on this same backend; the stored traces are the input of continuous eval.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — the Langfuse entry alongside Phoenix and the rest of the observability ecosystem.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — where Langfuse fits (layer 5, LLM-aware observability) in the whole building and how it is sized on the same 4×H100 cluster.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLMs on Kubernetes with KEDA&lt;/a> — the concrete mechanism for scaling the Langfuse Workers by &lt;code>langfuse.queue.ingestion.length&lt;/code> (knob 1), the same pattern as for vLLM.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS, ISO 42001 and the EU AI Act&lt;/a> — why Langfuse is best-effort observability and does not replace the regulatory audit log with durability guarantees.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the cable between the gateway and this backend: integration routes, &lt;code>traceparent&lt;/code> propagation and the four bounded queues that drop events before they reach the ingestion pipeline described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">Langfuse v4: what goes into a trace&lt;/a> — version 4 changed the data model underneath this architecture: there is no trace table any more. It opens the series of eight about operations.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Langfuse, &lt;em>Scaling Langfuse Deployments&lt;/em> (doc oficial de sizing y todos los env vars de este post): &lt;a href="https://langfuse.com/self-hosting/configuration/scaling">https://langfuse.com/self-hosting/configuration/scaling&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Self-host Langfuse&lt;/em> y &lt;em>Configuration via Environment Variables&lt;/em>: &lt;a href="https://langfuse.com/self-hosting">https://langfuse.com/self-hosting&lt;/a> · &lt;a href="https://langfuse.com/self-hosting/configuration">https://langfuse.com/self-hosting/configuration&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>ClickHouse (self-hosted)&lt;/em>: &lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/clickhouse">https://langfuse.com/self-hosting/deployment/infrastructure/clickhouse&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>From Zero to Scale: Langfuse&amp;rsquo;s Infrastructure Evolution&lt;/em> (el porqué del rediseño v2→v3): &lt;a href="https://langfuse.com/blog/2024-12-langfuse-v3-infrastructure-evolution">https://langfuse.com/blog/2024-12-langfuse-v3-infrastructure-evolution&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Migrate v2 to v3 (self-hosted)&lt;/em>: &lt;a href="https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v2-to-v3">https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v2-to-v3&lt;/a>.&lt;/li>
&lt;li>ClickHouse, &lt;em>Langfuse and ClickHouse: A new data stack for modern LLM applications&lt;/em>: &lt;a href="https://clickhouse.com/blog/langfuse-and-clickhouse-a-new-data-stack-for-modern-llm-applications">https://clickhouse.com/blog/langfuse-and-clickhouse-a-new-data-stack-for-modern-llm-applications&lt;/a>.&lt;/li>
&lt;li>Langfuse, issue #13468 — &lt;em>ClickhouseWriter drops rows after max flush attempts with no DLQ&lt;/em> (la ventana de pérdida documentada): &lt;a href="https://github.com/langfuse/langfuse/issues/13468">https://github.com/langfuse/langfuse/issues/13468&lt;/a>.&lt;/li>
&lt;li>ClickHouse, &lt;em>TTL for tables and columns&lt;/em>: &lt;a href="https://clickhouse.com/docs/guides/developer/ttl">https://clickhouse.com/docs/guides/developer/ttl&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, &lt;em>Semantic Conventions for Generative AI&lt;/em> (&lt;code>gen_ai.*&lt;/code>): &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">https://opentelemetry.io/docs/specs/semconv/gen-ai/&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Debezium and CDC: the notary who hears changes before anyone asks</title><link>https://blog.lo0.es/en/posts/debezium-cdc-notary-who-hears-changes/</link><pubDate>Thu, 04 Jun 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/debezium-cdc-notary-who-hears-changes/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Change Data Capture (CDC) with Debezium listens to the PostgreSQL Write-Ahead Log and turns every INSERT, UPDATE and DELETE into a structured Kafka event. Unlike traditional polling (&lt;code>SELECT ... WHERE updated_at &amp;gt; ?&lt;/code>), it detects deletes, has latency in the tens of milliseconds and adds no extra load to the database. In RAG pipelines, this means that when a document is deleted from Postgres, the Qdrant chunks disappear too, automatically and in real time. The supporting infrastructure is modest: the connector uses 2-4 cores and 4-8 GB RAM to process thousands of events per second.&lt;/p>
&lt;hr>
&lt;h2 id="the-master-analogy-the-land-registry-notary">The master analogy: the land registry notary&lt;/h2>
&lt;p>Picture the land registry. Every time a flat is sold, mortgaged, or a mortgage is cancelled, the registrar records the operation in the &lt;strong>registry ledger&lt;/strong>, a chronological and immutable journal. If you want to know what has changed in the registry, you have two options.&lt;/p>
&lt;p>&lt;strong>Option A (polling):&lt;/strong> you send someone every 5 minutes with a list of properties to ask &amp;ldquo;has anything changed?&amp;rdquo;. Problems: if a title was cancelled (DELETE), the property no longer exists by the time your envoy arrives, and there is no trace of it. If 20 different departments are doing the same thing, that is 20 people pestering the registrar every 5 minutes. And the minimum latency is the interval: 5 minutes.&lt;/p>
&lt;p>&lt;strong>Option B (Debezium):&lt;/strong> you hire a &lt;strong>notary&lt;/strong> who sits right at the registrar&amp;rsquo;s desk. Every time the registrar signs an operation into the ledger, the notary notes it down immediately and notifies whoever needs to know. Title cancellations included, because the notary sees them as clearly as any other operation: he was there when it was signed.&lt;/p>
&lt;p>In this analogy:&lt;/p>
&lt;ul>
&lt;li>The &lt;strong>registry ledger&lt;/strong> is the PostgreSQL &lt;strong>WAL&lt;/strong> (Write-Ahead Log).&lt;/li>
&lt;li>The &lt;strong>notary&lt;/strong> is the &lt;strong>Debezium connector&lt;/strong>.&lt;/li>
&lt;li>The notary&amp;rsquo;s &lt;strong>bookmark&lt;/strong>, which guarantees he loses no page even if he steps out for a moment, is the &lt;strong>logical replication slot&lt;/strong>.&lt;/li>
&lt;li>The &lt;strong>messenger&lt;/strong> who carries the notifications to interested parties is &lt;strong>Kafka&lt;/strong> (or Redpanda, or NATS JetStream).&lt;/li>
&lt;/ul>
&lt;p>We will pick this thread up in every section. When a technical detail is unclear, go back to the image of the notary.&lt;/p>
&lt;hr>
&lt;h2 id="1-the-problem-cdc-solves">1. The problem CDC solves&lt;/h2>
&lt;p>The most common synchronisation pattern between services sharing PostgreSQL is periodic polling:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1000&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This pattern has three structural problems.&lt;/p>
&lt;p>&lt;strong>DELETEs are invisible.&lt;/strong> When you delete a row, &lt;code>updated_at&lt;/code> is not updated: the row is gone. The next time the poller queries, the row does not exist and there is no way to know it ever did. In a RAG pipeline, this means &lt;strong>orphan chunks in Qdrant&lt;/strong>: the document no longer exists in Postgres, but its vectors keep polluting search results.&lt;/p>
&lt;p>&lt;strong>The minimum latency is the interval.&lt;/strong> If the poller runs every 5 seconds, average latency is 2.5 seconds. For near-real-time synchronisation (dashboards, alerts, RAG with frequently changing documents), that is too much.&lt;/p>
&lt;p>&lt;strong>Load scales with the number of consumers.&lt;/strong> If 10 services poll the same table every 5 seconds, that is 10 × 12 = 120 queries/minute producing no useful work; they only check whether there is anything new. On large tables with complex indexes, that is real load on the database.&lt;/p>
&lt;p>CDC inverts the model: &lt;strong>the database notifies, the consumers listen&lt;/strong>. Zero polling, zero extra load, DELETEs included, latency in the tens of milliseconds.&lt;/p>
&lt;hr>
&lt;h2 id="2-what-the-postgresql-wal-is">2. What the PostgreSQL WAL is&lt;/h2>
&lt;h3 id="the-operations-journal">The operations journal&lt;/h3>
&lt;p>The Write-Ahead Log (WAL) is the chronological, immutable record of every operation Postgres performs. Before modifying any data page on disk, Postgres writes the operation to the WAL. That sequence, log first then data, is what guarantees durability (the D in ACID) and enables crash recovery: if Postgres dies mid-transaction, on restart it replays the WAL to return the database to a consistent state.&lt;/p>
&lt;p>The WAL is the &lt;strong>registry ledger&lt;/strong> of our analogy: chronological, immutable, complete.&lt;/p>
&lt;h3 id="physical-vs-logical-replication">Physical vs logical replication&lt;/h3>
&lt;p>PostgreSQL supports two WAL-based replication modes:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Physical replication:&lt;/strong> it replicates disk blocks verbatim. The standby receives the same bytes as the primary. It serves high availability and failover, but the target must be an exact copy of Postgres; you cannot send the changes to an external application.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Logical replication:&lt;/strong> instead of disk blocks, it replicates &lt;strong>semantic operations&lt;/strong>: &amp;ldquo;the row with id=42 was inserted into table &lt;code>documents&lt;/code> with these values&amp;rdquo;. The target can be anything that understands the protocol: another Postgres, Debezium, or any custom consumer.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>CDC uses logical replication. It is what lets Debezium understand &amp;ldquo;what changed and in which table&amp;rdquo; rather than &amp;ldquo;which disk block changed at which offset&amp;rdquo;.&lt;/p>
&lt;h3 id="the-replication-slot-the-notarys-bookmark">The replication slot: the notary&amp;rsquo;s bookmark&lt;/h3>
&lt;p>A &lt;strong>logical replication slot&lt;/strong> is a persistent cursor in the WAL. Postgres keeps a record of how far into the WAL each slot has consumed. While a slot exists, Postgres &lt;strong>guarantees it will not discard the WAL segments the slot has not yet read&lt;/strong>.&lt;/p>
&lt;p>This is exactly the notary&amp;rsquo;s bookmark: even if the notary goes out for lunch, the ledger stays open at the last page he read. When he comes back, he carries on from exactly where he left off, having lost nothing.&lt;/p>
&lt;p>The risk is the inverse: &lt;strong>if the notary never comes back&lt;/strong>, the bookmark stops the registrar from archiving the old pages. If the Debezium connector goes down and does not recover for hours, the WAL grows indefinitely on disk until the slot is deleted manually or the consumer starts consuming again. This is called &lt;strong>WAL disk blowup&lt;/strong> and is the most important operational risk of Debezium.&lt;/p>
&lt;p>Mandatory monitoring:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">slot_name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">confirmed_flush_lsn&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">pg_current_wal_lsn&lt;/span>&lt;span class="p">(),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">pg_wal_lsn_diff&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pg_current_wal_lsn&lt;/span>&lt;span class="p">(),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">confirmed_flush_lsn&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">lag_bytes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_replication_slots&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">slot_type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;logical&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="the-pgoutput-plugin">The pgoutput plugin&lt;/h3>
&lt;p>The WAL stores operations in an internal binary format. For Debezium to understand them, Postgres needs to &lt;strong>decode&lt;/strong> them into a readable format. The &lt;code>pgoutput&lt;/code> decoding plugin, included in the Postgres core since version 10, does exactly that: it translates the binary WAL events into messages with the before/after structure of each row.&lt;/p>
&lt;p>Debezium uses &lt;code>pgoutput&lt;/code> by default. It requires no external extensions (unlike the &lt;code>wal2json&lt;/code> plugin that was popular before Postgres 10).&lt;/p>
&lt;hr>
&lt;h2 id="3-debezium-architecture">3. Debezium architecture&lt;/h2>
&lt;h3 id="the-connector-as-a-kafka-connect-plugin">The connector as a Kafka Connect plugin&lt;/h3>
&lt;p>Debezium is not a standalone service; it is a plugin for the &lt;strong>Kafka Connect&lt;/strong> framework. Kafka Connect manages the connector&amp;rsquo;s lifecycle (start, stop, reconnection, offset tracking) and provides the parallelism and fault tolerance infrastructure.&lt;/p>
&lt;p>The connector talks to Postgres through the logical replication protocol (not over JDBC), using the credentials of a user with the &lt;code>REPLICATION&lt;/code> role.&lt;/p>
&lt;pre tabindex="0">&lt;code>PostgreSQL (WAL + pgoutput)
│
│ logical replication protocol
▼
Debezium Connector (Kafka Connect worker)
│
│ Kafka Producer API
▼
Kafka topic: rag.public.documents
│
▼
Consumer (Qdrant sync, audit log, fine-tuning pipeline...)
&lt;/code>&lt;/pre>&lt;h3 id="structure-of-a-debezium-event">Structure of a Debezium event&lt;/h3>
&lt;p>Each change in the table becomes a JSON message with this structure:&lt;/p>
&lt;p>&lt;strong>INSERT (&lt;code>&amp;quot;op&amp;quot;: &amp;quot;c&amp;quot;&lt;/code> — create):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;before&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;after&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">42&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Contrato de arrendamiento...&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;tenant_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acme&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;updated_at&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1748934000000&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;op&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;c&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;source&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2.7.0.Final&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;connector&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgresql&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;db&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rag_db&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;schema&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;public&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;table&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;documents&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;lsn&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">29823948&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;txId&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1047&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;ts_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1748934000123&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>DELETE (&lt;code>&amp;quot;op&amp;quot;: &amp;quot;d&amp;quot;&lt;/code>):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;before&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">42&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Contrato de arrendamiento...&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;tenant_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acme&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;updated_at&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1748934000000&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;after&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;op&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;source&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;lsn&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">29824102&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;txId&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1051&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;ts_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1748934060200&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>before&lt;/code> field holds the row&amp;rsquo;s previous state, available because Postgres can be configured with &lt;strong>REPLICA IDENTITY FULL&lt;/strong> to include the complete row in the WAL on delete or update. Without that setting, &lt;code>before&lt;/code> contains only the primary key.&lt;/p>
&lt;p>&lt;strong>This is the key for the RAG pipeline&lt;/strong>: the DELETE event carries the document &lt;code>id&lt;/code>. The consumer uses it to delete every associated chunk in Qdrant with a &lt;code>doc_id = 42&lt;/code> filter. Without CDC, those chunks would never have been deleted.&lt;/p>
&lt;h3 id="initial-snapshot">Initial snapshot&lt;/h3>
&lt;p>When the connector starts for the first time (or after a reset), it cannot begin consuming the WAL from &amp;ldquo;the beginning of time&amp;rdquo;, only from the moment the slot was created. How does it guarantee the consistency of the initial state?&lt;/p>
&lt;p>Through a &lt;strong>transactional snapshot&lt;/strong>: the connector opens a transaction in &lt;code>REPEATABLE READ&lt;/code> mode, exports the snapshot ID (&lt;code>pg_export_snapshot()&lt;/code>), and does a full &lt;code>SELECT&lt;/code> of the configured tables inside that transaction. It then starts consuming the WAL from the snapshot&amp;rsquo;s LSN. That way there is no gap: the snapshot covers the state up to an instant, and the WAL covers from that instant onwards.&lt;/p>
&lt;h3 id="smt-transformations-single-message-transforms">SMT transformations (Single Message Transforms)&lt;/h3>
&lt;p>Before emitting the event to the Kafka topic, the connector can apply inline transformations called &lt;strong>SMT&lt;/strong>. Common use cases:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Filtering sensitive columns&lt;/strong> (&lt;code>ReplaceField&lt;/code> with &lt;code>blacklist&lt;/code>): strip &lt;code>password_hash&lt;/code>, &lt;code>phone_number&lt;/code> before they reach the topic.&lt;/li>
&lt;li>&lt;strong>Adding metadata&lt;/strong> (&lt;code>InsertField&lt;/code>): enrich the event with a &lt;code>tenant_id&lt;/code> extracted from the original HTTP header (if it is in the row).&lt;/li>
&lt;li>&lt;strong>Conditional routing&lt;/strong> (&lt;code>Filter&lt;/code>): drop events from rows with &lt;code>status = 'draft'&lt;/code> before emitting them.&lt;/li>
&lt;/ul>
&lt;p>SMTs are pure configuration, requiring no code, and they are applied inside the connector process with no perceptible extra latency.&lt;/p>
&lt;hr>
&lt;h2 id="4-debezium-vs-the-outbox-pattern">4. Debezium vs the Outbox pattern&lt;/h2>
&lt;p>The &lt;strong>Outbox pattern&lt;/strong> is the most common alternative to pure CDC. Instead of emitting events directly to Kafka, the application writes to an &lt;code>outbox&lt;/code> table in Postgres within the same transaction that modifies the data. A separate worker reads that table and publishes the events.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>Debezium (pure CDC)&lt;/th>
&lt;th>Outbox pattern&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Event latency&lt;/strong>&lt;/td>
&lt;td>~50-200 ms from commit&lt;/td>
&lt;td>Depends on the worker interval (typically 1-5 s)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Consistency&lt;/strong>&lt;/td>
&lt;td>At-least-once&lt;/td>
&lt;td>At-least-once&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DELETE detection&lt;/strong>&lt;/td>
&lt;td>Native (the DELETE event includes &lt;code>before&lt;/code>)&lt;/td>
&lt;td>Only if the app writes to the outbox on delete&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Setup complexity&lt;/strong>&lt;/td>
&lt;td>High (Kafka Connect, replication slot, permissions)&lt;/td>
&lt;td>Low (an extra table plus a simple worker)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Infrastructure dependency&lt;/strong>&lt;/td>
&lt;td>Requires Kafka/Redpanda/NATS JetStream&lt;/td>
&lt;td>Postgres plus a worker; Kafka optional&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>WAL disk blowup risk&lt;/strong>&lt;/td>
&lt;td>Yes, if the slot stops consuming&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Schema visibility&lt;/strong>&lt;/td>
&lt;td>Reads the table&amp;rsquo;s real schema&lt;/td>
&lt;td>The event schema is defined by the app&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Schema migration&lt;/strong>&lt;/td>
&lt;td>Needs care (events reflect DDL changes)&lt;/td>
&lt;td>More flexible (the event is whatever the app puts there)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When to use it&lt;/strong>&lt;/td>
&lt;td>When you need DELETEs, low latency, or you cannot modify the app&lt;/td>
&lt;td>When the app controls the event domain and infrastructure is limited&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Rule of thumb:&lt;/strong> if you control the application code and do not need native DELETEs, the Outbox is simpler. If you do not control the code (legacy database, third-party application) or DELETEs are critical (a RAG pipeline with document deletion), Debezium is the right choice.&lt;/p>
&lt;hr>
&lt;h2 id="5-the-maths">5. The maths&lt;/h2>
&lt;h3 id="throughput">Throughput&lt;/h3>
&lt;p>Debezium on a connector with 4 workers can process between &lt;strong>10,000 and 50,000 events/second&lt;/strong> on modest hardware (4 cores, 8 GB RAM). The real bottleneck is not the connector but the Kafka broker: with 3 brokers and suitable partitions, Kafka can comfortably sustain 500,000 messages/second with 1 KB messages (source: public Confluent benchmarks, 2023).&lt;/p>
&lt;p>For a typical RAG pipeline with 100 documents modified per minute:&lt;/p>
$$\text{events/s} = \frac{100}{60} \approx 1{.}7 \text{ events/s}$$
&lt;p>That is 0.0034% of the connector&amp;rsquo;s capacity. Debezium will not be the bottleneck in any realistic RAG scenario.&lt;/p>
&lt;h3 id="end-to-end-latency">End-to-end latency&lt;/h3>
&lt;p>The path from a commit in Postgres to an upsert in Qdrant has these stages:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th>Typical latency&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Commit in Postgres → WAL written&lt;/td>
&lt;td>&amp;lt; 1 ms (synchronous with the commit)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>WAL written → Debezium reads it (WAL lag)&lt;/td>
&lt;td>10-50 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Debezium → Kafka produce (ack)&lt;/td>
&lt;td>5-20 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kafka → Consumer (poll interval)&lt;/td>
&lt;td>0-100 ms (configurable)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Consumer → Qdrant upsert/delete&lt;/td>
&lt;td>5-15 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Typical total&lt;/strong>&lt;/td>
&lt;td>&lt;strong>30-200 ms&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With &lt;code>fetch.min.bytes=1&lt;/code> and &lt;code>fetch.max.wait.ms=10&lt;/code> on the consumer, the Kafka poll latency drops to ~10 ms. The realistic range for an optimised pipeline is &lt;strong>30-100 ms&lt;/strong>.&lt;/p>
&lt;h3 id="wal-disk-blowup-risk">WAL disk blowup risk&lt;/h3>
&lt;p>If the connector stops consuming, Postgres retains the WAL from the slot&amp;rsquo;s &lt;code>confirmed_flush_lsn&lt;/code> onwards. The retained volume grows linearly with time and the write rate:&lt;/p>
$$\text{retained WAL} = \text{write rate} \times \text{average WAL event size} \times \text{time without consuming}$$
&lt;p>Example with a moderate load (50,000 writes/hour, 500 bytes average per WAL event):&lt;/p>
$$50{,}000 \times 500 \text{ B} \times 1 \text{ h} = 25 \text{ MB/h}$$
&lt;p>With a high load (1,000,000 writes/hour):&lt;/p>
$$1{,}000{,}000 \times 500 \text{ B} \times 1 \text{ h} = 500 \text{ MB/h}$$
&lt;p>If the connector is down for 48 hours under high load: &lt;strong>24 GB of retained WAL&lt;/strong>. That can fill the disk and block Postgres completely.&lt;/p>
&lt;p>&lt;strong>Recommended alert:&lt;/strong> set an alert when &lt;code>lag_bytes &amp;gt; 1 GB&lt;/code> or when &lt;code>confirmed_flush_lsn&lt;/code> has not advanced for more than 15 minutes. See the monitoring query in section 2.&lt;/p>
&lt;hr>
&lt;h2 id="6-use-cases-in-llmops--rag">6. Use cases in LLMOps / RAG&lt;/h2>
&lt;h3 id="rag-synchronisation-with-real-deletion">RAG synchronisation with real deletion&lt;/h3>
&lt;p>This is the use case that most clearly justifies Debezium over polling. The flow:&lt;/p>
&lt;ol>
&lt;li>A user deletes document &lt;code>id=42&lt;/code> from the document management interface.&lt;/li>
&lt;li>Postgres runs &lt;code>DELETE FROM documents WHERE id = 42&lt;/code>.&lt;/li>
&lt;li>Debezium detects the DELETE in the WAL and emits the event with &lt;code>&amp;quot;op&amp;quot;: &amp;quot;d&amp;quot;&lt;/code> and &lt;code>&amp;quot;before&amp;quot;: {&amp;quot;id&amp;quot;: 42, ...}&lt;/code>.&lt;/li>
&lt;li>The consumer receives the event and runs:
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">qdrant_client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">delete&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;documents&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">points_selector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">must&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;doc_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">match&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">MatchValue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">42&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;/li>
&lt;li>Every chunk with &lt;code>doc_id=42&lt;/code> disappears from Qdrant in ~100 ms.&lt;/li>
&lt;/ol>
&lt;p>Without Debezium, those chunks would stay indefinitely, polluting retrieval results with fragments of documents that no longer exist in the source of truth.&lt;/p>
&lt;h3 id="event-sourcing-for-fine-tuning-datasets">Event sourcing for fine-tuning datasets&lt;/h3>
&lt;p>Every time a human annotator updates a row in the &lt;code>annotations&lt;/code> table (correcting an LLM output), Debezium emits the UPDATE with &lt;code>before&lt;/code> and &lt;code>after&lt;/code>. The consumer writes the pair (original_output, correction) into the dataset curation pipeline, with no need for the annotator to do anything beyond saving in the interface. The fine-tuning pipeline knows exactly what changed and when, with no polling and no risk of duplicates from overlapping time windows.&lt;/p>
&lt;h3 id="immutable-audit-log">Immutable audit log&lt;/h3>
&lt;p>WAL events are, by definition, the most faithful record of what happened in the database: they are the same data Postgres uses for crash recovery. Kafka with long retention (90 days, or size-based retention) serves as an &lt;strong>immutable audit log&lt;/strong> without modifying the application schema or adding triggers. This is particularly useful in regulated environments where traceability of data modifications is required.&lt;/p>
&lt;hr>
&lt;h2 id="7-architecture-diagram">7. Architecture diagram&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Debezium CDC architecture diagram: PostgreSQL, Debezium, Kafka, Consumer and Qdrant">
&lt;defs>
&lt;marker id="arrow-deb" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">&lt;path d="M0,0 L0,6 L8,3 z" fill="#64748b"/>&lt;/marker>
&lt;/defs>
&lt;rect x="0" y="0" width="820" height="340" fill="#f8f9fa" rx="8"/>
&lt;rect x="20" y="60" width="170" height="220" fill="#dbeafe" stroke="#3b82f6" stroke-width="1.5" rx="8"/>
&lt;text x="105" y="84" font-family="monospace" font-size="13" font-weight="bold" fill="#1e40af" text-anchor="middle">PostgreSQL&lt;/text>
&lt;rect x="36" y="96" width="138" height="52" fill="#bfdbfe" stroke="#3b82f6" stroke-width="1" rx="4"/>
&lt;text x="105" y="116" font-family="monospace" font-size="11" fill="#1e3a8a" text-anchor="middle">WAL&lt;/text>
&lt;text x="105" y="131" font-family="monospace" font-size="10" fill="#1e3a8a" text-anchor="middle">(registry ledger)&lt;/text>
&lt;rect x="36" y="160" width="138" height="52" fill="#bfdbfe" stroke="#3b82f6" stroke-width="1" rx="4"/>
&lt;text x="105" y="180" font-family="monospace" font-size="11" fill="#1e3a8a" text-anchor="middle">Replication slot&lt;/text>
&lt;text x="105" y="195" font-family="monospace" font-size="10" fill="#1e3a8a" text-anchor="middle">(bookmark)&lt;/text>
&lt;rect x="36" y="224" width="138" height="40" fill="#bfdbfe" stroke="#3b82f6" stroke-width="1" rx="4"/>
&lt;text x="105" y="244" font-family="monospace" font-size="11" fill="#1e3a8a" text-anchor="middle">pgoutput&lt;/text>
&lt;text x="105" y="257" font-family="monospace" font-size="10" fill="#1e3a8a" text-anchor="middle">(decoding)&lt;/text>
&lt;line x1="190" y1="170" x2="248" y2="170" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-deb)"/>
&lt;text x="219" y="162" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">logical&lt;/text>
&lt;text x="219" y="173" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">replication&lt;/text>
&lt;rect x="248" y="110" width="160" height="120" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5" rx="8"/>
&lt;text x="328" y="134" font-family="monospace" font-size="13" font-weight="bold" fill="#14532d" text-anchor="middle">Debezium&lt;/text>
&lt;text x="328" y="151" font-family="monospace" font-size="10" fill="#14532d" text-anchor="middle">Connector&lt;/text>
&lt;text x="328" y="168" font-family="monospace" font-size="10" fill="#166534" text-anchor="middle">(Kafka Connect)&lt;/text>
&lt;rect x="264" y="178" width="112" height="38" fill="#bbf7d0" stroke="#16a34a" stroke-width="1" rx="4"/>
&lt;text x="320" y="193" font-family="monospace" font-size="9" fill="#14532d" text-anchor="middle">op: c / u / d&lt;/text>
&lt;text x="320" y="207" font-family="monospace" font-size="9" fill="#14532d" text-anchor="middle">before + after + LSN&lt;/text>
&lt;line x1="408" y1="170" x2="464" y2="170" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-deb)"/>
&lt;text x="436" y="162" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">Kafka&lt;/text>
&lt;text x="436" y="173" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">Producer&lt;/text>
&lt;rect x="464" y="110" width="150" height="120" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5" rx="8"/>
&lt;text x="539" y="134" font-family="monospace" font-size="13" font-weight="bold" fill="#713f12" text-anchor="middle">Kafka&lt;/text>
&lt;text x="539" y="151" font-family="monospace" font-size="10" fill="#713f12" text-anchor="middle">topic:&lt;/text>
&lt;text x="539" y="165" font-family="monospace" font-size="9" fill="#92400e" text-anchor="middle">rag.public.documents&lt;/text>
&lt;rect x="478" y="178" width="122" height="38" fill="#fef08a" stroke="#ca8a04" stroke-width="1" rx="4"/>
&lt;text x="539" y="193" font-family="monospace" font-size="9" fill="#713f12" text-anchor="middle">configurable retention&lt;/text>
&lt;text x="539" y="207" font-family="monospace" font-size="9" fill="#713f12" text-anchor="middle">at-least-once&lt;/text>
&lt;line x1="614" y1="170" x2="668" y2="170" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-deb)"/>
&lt;text x="641" y="162" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">Kafka&lt;/text>
&lt;text x="641" y="173" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">Consumer&lt;/text>
&lt;rect x="668" y="110" width="132" height="120" fill="#fce7f3" stroke="#db2777" stroke-width="1.5" rx="8"/>
&lt;text x="734" y="134" font-family="monospace" font-size="12" font-weight="bold" fill="#831843" text-anchor="middle">Consumer&lt;/text>
&lt;rect x="682" y="146" width="104" height="36" fill="#fbcfe8" stroke="#db2777" stroke-width="1" rx="4"/>
&lt;text x="734" y="162" font-family="monospace" font-size="9" fill="#831843" text-anchor="middle">INSERT/UPDATE&lt;/text>
&lt;text x="734" y="175" font-family="monospace" font-size="9" fill="#831843" text-anchor="middle">→ upsert Qdrant&lt;/text>
&lt;rect x="682" y="190" width="104" height="28" fill="#fbcfe8" stroke="#db2777" stroke-width="1" rx="4"/>
&lt;text x="734" y="204" font-family="monospace" font-size="9" fill="#831843" text-anchor="middle">DELETE&lt;/text>
&lt;text x="734" y="215" font-family="monospace" font-size="9" fill="#831843" text-anchor="middle">→ delete Qdrant&lt;/text>
&lt;line x1="734" y1="230" x2="734" y2="288" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-deb)"/>
&lt;rect x="668" y="288" width="132" height="40" fill="#ede9fe" stroke="#7c3aed" stroke-width="1.5" rx="8"/>
&lt;text x="734" y="313" font-family="monospace" font-size="12" font-weight="bold" fill="#4c1d95" text-anchor="middle">Qdrant&lt;/text>
&lt;rect x="20" y="295" width="620" height="28" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1" rx="4"/>
&lt;text x="330" y="313" font-family="monospace" font-size="10" fill="#475569" text-anchor="middle">Typical end-to-end latency: 30-100 ms from commit in Postgres to upsert/delete in Qdrant&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="8-minimum-configuration">8. Minimum configuration&lt;/h2>
&lt;h3 id="postgresql-enable-logical-replication">PostgreSQL: enable logical replication&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- Requires restarting Postgres
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SYSTEM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">wal_level&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">logical&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SYSTEM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">max_replication_slots&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SYSTEM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">max_wal_senders&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- Reload configuration (wal_level requires a full restart)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_reload_conf&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- Dedicated user for Debezium
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">USER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">debezium&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">REPLICATION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">LOGIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PASSWORD&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;cambiar_esto&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GRANT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">public&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">debezium&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- REPLICA IDENTITY FULL to get a complete &amp;#39;before&amp;#39; on DELETEs and UPDATEs
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">public&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">REPLICA&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IDENTITY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FULL&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="debezium-connector-kafka-connect-rest-api">Debezium connector (Kafka Connect REST API)&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgres-debezium&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;config&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;connector.class&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.debezium.connector.postgresql.PostgresConnector&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.hostname&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgres&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.port&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;5432&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.user&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;debezium&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.password&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;cambiar_esto&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.dbname&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rag_db&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;topic.prefix&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rag&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;table.include.list&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;public.documents&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;plugin.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;pgoutput&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;slot.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;debezium_rag&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;publication.autocreate.mode&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;filtered&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;snapshot.mode&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;initial&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;tombstones.on.delete&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;true&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;transforms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;unwrap&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;transforms.unwrap.type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.debezium.transforms.ExtractNewRecordState&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;transforms.unwrap.drop.tombstones&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;false&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;transforms.unwrap.delete.handling.mode&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rewrite&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Register the connector:&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://kafka-connect:8083/connectors &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s1">&amp;#39;Content-Type: application/json&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d @connector-config.json
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Check its status:&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 http://kafka-connect:8083/connectors/postgres-debezium/status
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="minimal-consumer-in-python">Minimal consumer in Python&lt;/h3>
&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">confluent_kafka&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Consumer&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">qdrant_client&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">QdrantClient&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">qdrant_client.models&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">MatchValue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">json&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">consumer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Consumer&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;bootstrap.servers&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;kafka:9092&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;group.id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;qdrant-sync&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;auto.offset.reset&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;earliest&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;enable.auto.commit&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">consumer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">subscribe&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s2">&amp;#34;rag.public.documents&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">qdrant&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">QdrantClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;qdrant&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">port&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6333&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">while&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">msg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">consumer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">poll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">timeout&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">msg&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">event&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">msg&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">op&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;op&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">op&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;c&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;u&amp;#34;&lt;/span>&lt;span class="p">):&lt;/span> &lt;span class="c1"># INSERT or UPDATE&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">doc&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;after&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># ... vectorise and upsert into Qdrant&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">elif&lt;/span> &lt;span class="n">op&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;d&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="c1"># DELETE&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">doc_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;before&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">qdrant&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">delete&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;documents&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">points_selector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">must&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;doc_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">match&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">MatchValue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">))]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">consumer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">commit&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="9-on-premise-deployment">9. On-premise deployment&lt;/h2>
&lt;p>The Debezium stack does not compete for GPU. On a node with &lt;strong>4×H100 SXM (320 GB, NVLink)&lt;/strong> serving the inference LLM, the CDC pipeline runs entirely on general-purpose (CPU-only) nodes:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Recommended resources&lt;/th>
&lt;th>Role&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Kafka Connect + Debezium&lt;/td>
&lt;td>2-4 cores, 4-8 GB RAM&lt;/td>
&lt;td>Read the WAL, emit events&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kafka brokers (×3)&lt;/td>
&lt;td>4 cores, 32 GB RAM each&lt;/td>
&lt;td>High availability, retention&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qdrant-sync consumer&lt;/td>
&lt;td>2 cores, 4 GB RAM&lt;/td>
&lt;td>Vectorise plus upsert/delete&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qdrant&lt;/td>
&lt;td>8 cores, 64 GB RAM&lt;/td>
&lt;td>Vector store&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The Debezium connector is notably light: in production at 10,000 events/second, the connector typically uses less than 1 core and 2 GB of RAM. JVM memory (Kafka Connect runs on the JVM) must be capped explicitly with &lt;code>-Xmx4g&lt;/code> to stop the GC from causing pauses.&lt;/p>
&lt;p>For high availability, Kafka Connect supports &lt;strong>distributed&lt;/strong> mode with multiple workers. If a worker dies, the connector is reassigned automatically to another worker within seconds, and the replication slot guarantees no events are lost during the switchover.&lt;/p>
&lt;hr>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Debezium with MySQL, MongoDB and Oracle&lt;/strong>: each connector uses the native log mechanism (binlog on MySQL, oplog on MongoDB, LogMiner on Oracle). The resulting event API is similar, but the configuration details and limitations differ.&lt;/li>
&lt;li>&lt;strong>Debezium Server&lt;/strong>: standalone mode without Kafka Connect, with direct sinks to HTTP, S3, Redis Streams or NATS. Useful when the Kafka infrastructure is too complex for the use case.&lt;/li>
&lt;li>&lt;strong>Schema Registry&lt;/strong>: how Avro with Confluent Schema Registry or Apicurio manages event schema evolution (adding columns, changing types) without breaking existing consumers.&lt;/li>
&lt;li>&lt;strong>Exactly-once semantics&lt;/strong>: why at-least-once is enough for most RAG cases (an idempotent upsert into Qdrant with the same vector does no harm) and when exactly-once is needed (financial counters, inventory deductions).&lt;/li>
&lt;li>&lt;strong>Outbox pattern plus Debezium combined&lt;/strong>: Debezium reading the &lt;code>outbox&lt;/code> table instead of the business table&amp;rsquo;s WAL directly, the Transactional Outbox + CDC pattern that combines the best of both worlds.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant: ingestion through microservices&lt;/a> — the post where CDC with Debezium is used as an alternative to the outbox pattern to keep PostgreSQL and Qdrant in sync.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation: fundamentals&lt;/a> — the corpus curation that Debezium keeps fresh in near real time.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">LLMOps pipeline: the six stages&lt;/a> — the Data stage of the master map, where CDC is the continuous ingestion mechanism.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning with DVC and lakeFS&lt;/a> — versioning the corpus that Debezium feeds incrementally.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM and LLM&lt;/a> — monitoring the cluster where the Debezium consumer runs alongside the inference stack.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>Debezium Documentation — PostgreSQL Connector. &lt;a href="https://debezium.io/documentation/reference/stable/connectors/postgresql.html">debezium.io/documentation/reference/stable/connectors/postgresql.html&lt;/a>&lt;/li>
&lt;li>PostgreSQL Documentation — Logical Replication. &lt;a href="https://www.postgresql.org/docs/current/logical-replication.html">postgresql.org/docs/current/logical-replication.html&lt;/a>&lt;/li>
&lt;li>PostgreSQL Documentation — Write-Ahead Logging. &lt;a href="https://www.postgresql.org/docs/current/wal-intro.html">postgresql.org/docs/current/wal-intro.html&lt;/a>&lt;/li>
&lt;li>PostgreSQL Documentation — Replication Slots. &lt;a href="https://www.postgresql.org/docs/current/logicaldecoding-explanation.html">postgresql.org/docs/current/logicaldecoding-explanation.html&lt;/a>&lt;/li>
&lt;li>Confluent — Kafka Performance Benchmarks (2023). &lt;a href="https://www.confluent.io/blog/kafka-fastest-messaging-system/">confluent.io/blog/kafka-fastest-messaging-system&lt;/a>&lt;/li>
&lt;li>Gunnar Morling — Outbox Pattern. &lt;a href="https://www.morling.dev/blog/sending-messages-as-part-of-database-transactions/">morling.dev/blog/sending-messages-as-part-of-database-transactions&lt;/a>&lt;/li>
&lt;li>Debezium — SMT documentation. &lt;a href="https://debezium.io/documentation/reference/stable/transformations/">debezium.io/documentation/reference/stable/transformations&lt;/a>&lt;/li>
&lt;li>Qdrant Documentation — Filtering. &lt;a href="https://qdrant.tech/documentation/concepts/filtering/">qdrant.tech/documentation/concepts/filtering&lt;/a>&lt;/li>
&lt;/ol></description></item><item><title>LLM tracing with OpenTelemetry GenAI: the aircraft black box the field stabilised in 2026</title><link>https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/</link><pubDate>Wed, 27 May 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/</guid><description>&lt;blockquote>
&lt;p>This post complements the one on &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a>, where &lt;code>prompt_id@version&lt;/code> appeared as a span attribute without the whole pipeline being explained, and the one on &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs&lt;/a>, which distinguishes tracing from eval. Here we go inside tracing itself.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>OpenTelemetry GenAI, the &lt;code>gen_ai.*&lt;/code> and &lt;code>mcp.*&lt;/code> semantic conventions, have been &lt;strong>stable since late 2025&lt;/strong>, and as of May 2026 they are the substrate on which the whole ecosystem (Langfuse, Phoenix, LangSmith, Braintrust, Arize, Honeycomb, Datadog, New Relic) builds its LLM observability. That means your team instruments once, against OTel, and picks a backend afterwards; not the other way round. The canonical pipeline is &lt;strong>application → OTel SDK (OpenLLMetry or openinference) → OTel Collector → backend(s)&lt;/strong>, with two-layer sampling (head-based at 1-5 % to keep volume viable, tail-based at 100 % over errors and high latencies). This post takes apart the exact attributes you have to fill in on each span (&lt;code>gen_ai.system&lt;/code>, &lt;code>request.model&lt;/code>, &lt;code>usage.input_tokens&lt;/code>, and so on), the operational difference between the three usual SDKs, and the anatomy of a real chat-with-RAG-and-tool trace with all its child spans.&lt;/p>
&lt;h2 id="you-are-here-observe">You are here: OBSERVE&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Observe">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#c9a8e9;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#otm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#otm)}&lt;/style>
&lt;defs>&lt;marker id="otm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: OBSERVE · tracing with OpenTelemetry GenAI&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box active"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-aircraft-black-box">The analogy: the aircraft black box&lt;/h2>
&lt;p>An aircraft black box does three things at once:&lt;/p>
&lt;ol>
&lt;li>It records everything without anyone having to ask for it explicitly. Every flight parameter, every cockpit conversation, every action by the pilots is logged with a precise timestamp and a standardised format.&lt;/li>
&lt;li>It uses a format everybody knows. Any air accident investigator anywhere in the world can read the black box of any manufacturer, because the schema is agreed between all of them. There is no &amp;ldquo;Boeing black box&amp;rdquo; and &amp;ldquo;Airbus black box&amp;rdquo;: there is one single black box.&lt;/li>
&lt;li>It survives the event: it is designed on the assumption that the aircraft may go down. It does not depend on the aircraft itself to be stored or to be read.&lt;/li>
&lt;/ol>
&lt;p>LLM tracing with OTel GenAI does exactly those three things:&lt;/p>
&lt;ol>
&lt;li>It records everything automatically. Every model call, every agent step, every tool call, every RAG retrieval ends up as a span, a unit of work with a start, an end, attributes and a parent/child relationship with other spans.&lt;/li>
&lt;li>It uses a common format. The attributes &lt;code>gen_ai.request.model&lt;/code>, &lt;code>gen_ai.usage.input_tokens&lt;/code>, &lt;code>gen_ai.response.finish_reasons&lt;/code> and the rest are &lt;strong>agreed by the OpenTelemetry GenAI working group&lt;/strong> and every backend respects them.&lt;/li>
&lt;li>It survives the backend. Spans do not travel straight to Langfuse: they travel to an intermediate &lt;strong>OTel Collector&lt;/strong> that handles buffering, sampling and fan-out to 1, 2 or N backends. If Langfuse goes down, the Collector holds the spans.&lt;/li>
&lt;/ol>
&lt;p>The analogy is not decorative: it is the only way to understand why OTel GenAI beat the ecosystem of proprietary SDKs that every vendor shipped in 2023-2024. &lt;strong>The standardised black box beats the proprietary recorder, every time.&lt;/strong>&lt;/p>
&lt;h2 id="why-otel-genai-exists-and-why-it-won">Why OTel GenAI exists (and why it won)&lt;/h2>
&lt;p>The state of things at the end of 2024 was broken: every vendor shipped its own SDK with its own attribute names. LangSmith called it &lt;code>model_name&lt;/code>, OpenLLMetry called it &lt;code>llm.model&lt;/code>, Helicone called it &lt;code>model&lt;/code>. If you wanted to change observability provider, you re-instrumented the whole application. For serious teams, that was unacceptable.&lt;/p>
&lt;p>OpenTelemetry already had a framework to solve this: the &lt;strong>semantic conventions&lt;/strong>. A working group (the OpenTelemetry GenAI SIG) proposed a schema. In 2025 it reached &amp;ldquo;Stable&amp;rdquo; for the attributes of the basic modality (chat completions + tool calls), and in 2026 it also covers multimodal, embeddings and reasoning modalities.&lt;/p>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 290" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OTel GenAI pipeline">
&lt;style>
.bx{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.bs{fill:#ffe6d6}
.bc{fill:#d6eaff}
.bb{fill:#d9f5d6}
.bm{fill:#fff5b0}
.t{font:700 13px sans-serif;fill:#222}
.s{font:400 11px sans-serif;fill:#555}
.h{font:700 14px sans-serif;fill:#222}
.ar{stroke:#666;stroke-width:1.6;fill:none;marker-end:url(#ot1)}
.aw{stroke:#27ae60;stroke-width:1.8;fill:none;marker-end:url(#ot2)}
&lt;/style>
&lt;defs>
&lt;marker id="ot1" 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="ot2" 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="#27ae60"/>&lt;/marker>
&lt;/defs>
&lt;text x="380" y="22" text-anchor="middle" class="h">OTel GenAI pipeline: instrument once, choose the backend later&lt;/text>
&lt;rect x="30" y="50" width="160" height="80" class="bx bs"/>
&lt;text x="110" y="74" text-anchor="middle" class="t">Application&lt;/text>
&lt;text x="110" y="92" text-anchor="middle" class="s">Python / Node / Go&lt;/text>
&lt;text x="110" y="108" text-anchor="middle" class="s">+ OpenLLMetry&lt;/text>
&lt;text x="110" y="124" text-anchor="middle" class="s">or openinference SDK&lt;/text>
&lt;rect x="230" y="50" width="160" height="80" class="bx bc"/>
&lt;text x="310" y="74" text-anchor="middle" class="t">OTel Collector&lt;/text>
&lt;text x="310" y="92" text-anchor="middle" class="s">buffering + sampling +&lt;/text>
&lt;text x="310" y="108" text-anchor="middle" class="s">batching + retry +&lt;/text>
&lt;text x="310" y="124" text-anchor="middle" class="s">routing per exporter&lt;/text>
&lt;rect x="430" y="20" width="160" height="40" class="bx bb"/>
&lt;text x="510" y="38" text-anchor="middle" class="t">Langfuse&lt;/text>
&lt;text x="510" y="52" text-anchor="middle" class="s">LLM-first UI, eval, prompts&lt;/text>
&lt;rect x="430" y="80" width="160" height="40" class="bx bb"/>
&lt;text x="510" y="98" text-anchor="middle" class="t">Tempo / Jaeger&lt;/text>
&lt;text x="510" y="112" text-anchor="middle" class="s">distributed tracing infra&lt;/text>
&lt;rect x="430" y="140" width="160" height="40" class="bx bm"/>
&lt;text x="510" y="158" text-anchor="middle" class="t">Prometheus&lt;/text>
&lt;text x="510" y="172" text-anchor="middle" class="s">aggregated metrics&lt;/text>
&lt;rect x="430" y="200" width="160" height="40" class="bx bm"/>
&lt;text x="510" y="218" text-anchor="middle" class="t">ClickHouse / OpenSearch&lt;/text>
&lt;text x="510" y="232" text-anchor="middle" class="s">log and event storage&lt;/text>
&lt;rect x="630" y="80" width="100" height="40" class="bx bb"/>
&lt;text x="680" y="98" text-anchor="middle" class="t">Phoenix&lt;/text>
&lt;text x="680" y="112" text-anchor="middle" class="s">optional, eval-first&lt;/text>
&lt;rect x="630" y="140" width="100" height="40" class="bx bb"/>
&lt;text x="680" y="158" text-anchor="middle" class="t">Datadog/NR&lt;/text>
&lt;text x="680" y="172" text-anchor="middle" class="s">infra-side&lt;/text>
&lt;path class="ar" d="M190,90 L230,90"/>
&lt;path class="ar" d="M390,90 L430,40"/>
&lt;path class="ar" d="M390,90 L430,100"/>
&lt;path class="ar" d="M390,90 L430,160"/>
&lt;path class="ar" d="M390,100 L430,220"/>
&lt;path class="aw" d="M590,100 L630,100"/>
&lt;path class="aw" d="M590,160 L630,160"/>
&lt;text x="110" y="170" text-anchor="middle" class="s" fill="#c0392b">the SDK adds spans automatically&lt;/text>
&lt;text x="110" y="184" text-anchor="middle" class="s" fill="#c0392b">with gen_ai.* attributes&lt;/text>
&lt;text x="310" y="170" text-anchor="middle" class="s" fill="#27ae60">you switch backend by touching only&lt;/text>
&lt;text x="310" y="184" text-anchor="middle" class="s" fill="#27ae60">the Collector's exporter&lt;/text>
&lt;text x="380" y="265" text-anchor="middle" class="s" font-style="italic">If Langfuse goes down, the Collector holds the spans and re-emits them when it returns.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>What changes in practice:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Before (proprietary SDKs)&lt;/strong>: you move from Langfuse to Phoenix → you re-instrument, re-deploy everything, lose the history.&lt;/li>
&lt;li>&lt;strong>Now (OTel)&lt;/strong>: you move from Langfuse to Phoenix → you edit the exporter in &lt;code>otel-collector-config.yaml&lt;/code>, reload the Collector, and the new spans go to the new destination without touching a line of application code.&lt;/li>
&lt;/ul>
&lt;h2 id="the-canonical-gen_ai-attributes">The canonical &lt;code>gen_ai.*&lt;/code> attributes&lt;/h2>
&lt;p>As of May 2026 the stable attributes, that is, the ones that are not going to change, are those in the table. There are more in &amp;ldquo;experimental&amp;rdquo; state (multimodal, reasoning) and others in &amp;ldquo;deprecated&amp;rdquo; (kept for compatibility).&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Meaning&lt;/th>
&lt;th>Example&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>gen_ai.system&lt;/code>&lt;/td>
&lt;td>Provider family&lt;/td>
&lt;td>&lt;code>openai&lt;/code>, &lt;code>anthropic&lt;/code>, &lt;code>vllm&lt;/code>, &lt;code>huggingface&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.request.model&lt;/code>&lt;/td>
&lt;td>Model requested&lt;/td>
&lt;td>&lt;code>gpt-4o&lt;/code>, &lt;code>meta-llama/Llama-3.1-8B-Instruct&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.request.temperature&lt;/code>&lt;/td>
&lt;td>Sampling temp&lt;/td>
&lt;td>&lt;code>0.7&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.request.max_tokens&lt;/code>&lt;/td>
&lt;td>Output cap&lt;/td>
&lt;td>&lt;code>1024&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.request.top_p&lt;/code>&lt;/td>
&lt;td>Nucleus sampling&lt;/td>
&lt;td>&lt;code>0.95&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.response.model&lt;/code>&lt;/td>
&lt;td>Model that answered (may differ)&lt;/td>
&lt;td>&lt;code>gpt-4o-2024-08-06&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.response.id&lt;/code>&lt;/td>
&lt;td>ID of the provider&amp;rsquo;s response&lt;/td>
&lt;td>&lt;code>chatcmpl-9xY...&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.response.finish_reasons&lt;/code>&lt;/td>
&lt;td>Finish reasons&lt;/td>
&lt;td>&lt;code>[&amp;quot;stop&amp;quot;]&lt;/code>, &lt;code>[&amp;quot;length&amp;quot;]&lt;/code>, &lt;code>[&amp;quot;tool_calls&amp;quot;]&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.usage.input_tokens&lt;/code>&lt;/td>
&lt;td>Input tokens&lt;/td>
&lt;td>&lt;code>1247&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.usage.output_tokens&lt;/code>&lt;/td>
&lt;td>Output tokens&lt;/td>
&lt;td>&lt;code>412&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.operation.name&lt;/code>&lt;/td>
&lt;td>Operation type&lt;/td>
&lt;td>&lt;code>chat&lt;/code>, &lt;code>text_completion&lt;/code>, &lt;code>embeddings&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.prompt.id&lt;/code>&lt;/td>
&lt;td>ID of the versioned prompt&lt;/td>
&lt;td>&lt;code>customer_support_v3&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.prompt.version&lt;/code>&lt;/td>
&lt;td>Specific version&lt;/td>
&lt;td>&lt;code>14&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.prompt.label&lt;/code>&lt;/td>
&lt;td>Semantic label&lt;/td>
&lt;td>&lt;code>production&lt;/code>, &lt;code>staging&lt;/code>, &lt;code>canary&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.conversation.id&lt;/code>&lt;/td>
&lt;td>Multiturn conversation ID&lt;/td>
&lt;td>&lt;code>session_abc123&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>And for tool calls (a function invoked by the model), the &lt;code>gen_ai.tool.*&lt;/code> attributes:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Meaning&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.call.id&lt;/code>&lt;/td>
&lt;td>ID of the specific tool call&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.name&lt;/code>&lt;/td>
&lt;td>Name of the tool&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.type&lt;/code>&lt;/td>
&lt;td>&lt;code>function&lt;/code>, &lt;code>mcp&lt;/code>, and so on&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.description&lt;/code>&lt;/td>
&lt;td>Short description&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>And for tool calls over MCP (Model Context Protocol), the &lt;code>mcp.*&lt;/code> attributes add a specific layer (&lt;code>mcp.server.name&lt;/code>, &lt;code>mcp.method&lt;/code>, &lt;code>mcp.session.id&lt;/code> and others) that coexists with the &lt;code>gen_ai.*&lt;/code> ones.&lt;/p>
&lt;p>&lt;strong>The practical rule&lt;/strong>: if your instrumentation fills in these attributes on every span, the Langfuse dashboard, the Tempo graph and the Prometheus metrics come out &amp;ldquo;for free&amp;rdquo;, because all three know how to read the convention.&lt;/p>
&lt;h2 id="the-canonical-pipeline-sdk--collector--backends">The canonical pipeline: SDK → Collector → backends&lt;/h2>
&lt;p>Three layers, each with its own responsibility:&lt;/p>
&lt;h3 id="1--the-sdk">1 · The SDK&lt;/h3>
&lt;p>Three dominant options in 2026:&lt;/p>
&lt;p>&lt;strong>OpenLLMetry (Traceloop)&lt;/strong> — the most widespread. It automatically instruments the OpenAI Python SDK, the Anthropic SDK, LangChain, LlamaIndex, Haystack, Cohere, Mistral, vLLM and almost any library in the ecosystem. A single line (&lt;code>Traceloop.init()&lt;/code>) instruments everything. Apache 2.0 licence. It maintains its own fork with extensions that are not yet in OTel core, but it exports in line with the convention.&lt;/p>
&lt;p>&lt;strong>openinference (Arize)&lt;/strong> — a direct competitor, also Apache 2.0. Closer to Phoenix (same vendor) but it exports standard OTel. Better instrumentation for LangChain/LlamaIndex/DSPy in some versions; worse for vLLM directly.&lt;/p>
&lt;p>&lt;strong>Langfuse SDK&lt;/strong> — proprietary in form (the API is Langfuse&amp;rsquo;s), but underneath it emits OTel spans. The natural choice if Langfuse is the main backend. It has the best support for &amp;ldquo;session&amp;rdquo; and &amp;ldquo;user&amp;rdquo; linking, concepts that are not yet in OTel core but that Langfuse maps to &lt;code>gen_ai.conversation.id&lt;/code> for compatibility.&lt;/p>
&lt;p>Practical recommendation for May 2026: &lt;strong>OpenLLMetry if you want to stay backend-agnostic&lt;/strong> (you will change provider); &lt;strong>the Langfuse SDK if Langfuse is already your bet&lt;/strong> (you save yourself a mapping). Both produce valid spans that any Collector consumes.&lt;/p>
&lt;h3 id="2--the-otel-collector">2 · The OTel Collector&lt;/h3>
&lt;p>The Collector is the &lt;strong>most important piece&lt;/strong> of the pipeline and the least talked about. Three responsibilities:&lt;/p>
&lt;p>&lt;strong>Buffering&lt;/strong>: if Langfuse or Tempo have a problem, the Collector holds the spans in memory (or on disk with persistence enabled) and re-emits them when the backend returns.&lt;/p>
&lt;p>&lt;strong>Sampling&lt;/strong>: it applies the two sampling layers (next section) without the application knowing anything about it.&lt;/p>
&lt;p>&lt;strong>Routing&lt;/strong>: with the &lt;code>exporters&lt;/code> config it points at one or several backends at once. The usual practice is to &lt;strong>send to Langfuse + Tempo + Prometheus simultaneously&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>Langfuse for the LLM-first UI (prompts, evals, user sessions).&lt;/li>
&lt;li>Tempo (or Jaeger) for the full distributed context (an LLM span inside an HTTP request that has touched 12 microservices).&lt;/li>
&lt;li>Prometheus for aggregated metrics (P95 latency per model, tokens per second, error rate).&lt;/li>
&lt;/ul>
&lt;p>A representative production fragment of &lt;code>otel-collector-config.yaml&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">otlp&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocols&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">grpc&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0.0.0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">4317&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">http&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0.0.0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">4318&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">batch&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">1s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">send_batch_size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1024&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tail_sampling&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">decision_wait&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">10s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">errors&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">status_code&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">status_code&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">status_codes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">ERROR] }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">slow_traces&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">latency&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">latency&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold_ms&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5000&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">head_5pct&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">probabilistic&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">probabilistic&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">sampling_percentage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">otlphttp/langfuse&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://langfuse.internal:3000/api/public/otel/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">headers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">Authorization&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Basic ${LANGFUSE_AUTH}&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">otlp/tempo&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">tempo:4317&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tls&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">insecure&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0.0.0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">8889&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">service&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pipelines&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">traces&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">otlp]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">tail_sampling, batch]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">otlphttp/langfuse, otlp/tempo]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metrics&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">otlp]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">batch]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">prometheus]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="3--the-backends">3 · The backends&lt;/h3>
&lt;p>&lt;strong>Langfuse&lt;/strong> is the dominant option for OSS LLM observability (MIT licence, complete suite: tracing + evals + prompts + datasets). Its model assumes OTel &lt;code>trace_id&lt;/code> and &lt;code>span_id&lt;/code> and draws them in the UI with the LLM context expanded (formatted messages, counted tokens, costs calculated from a model→price mapping).&lt;/p>
&lt;p>&lt;strong>Phoenix&lt;/strong> (Arize, ELv2 licence) has a more eval-first focus. Useful if your team comes from classic NLP and wants UMAP embedding visualisation by default.&lt;/p>
&lt;p>&lt;strong>LangSmith&lt;/strong> (LangChain) is proprietary, not OSS. Excellent integration with LangChain/LangGraph; less relevant if you do not use that stack.&lt;/p>
&lt;p>&lt;strong>Tempo / Jaeger&lt;/strong> show the LLM traces &lt;strong>inside the context of the distributed request&lt;/strong>. It is what you need when a customer reports that &amp;ldquo;the page takes 8 seconds&amp;rdquo; and you want to see whether those 8 seconds are the model, the retrieval or the downstream microservice.&lt;/p>
&lt;h2 id="sampling-the-two-layers-that-coexist">Sampling: the two layers that coexist&lt;/h2>
&lt;p>Without sampling, a system at 10 req/s generates about 26 million spans a month. The backend drowns, the cost explodes and most of the spans are redundant (200 similar requests tell you no more than 20).&lt;/p>
&lt;p>The canonical pattern in 2026 is &lt;strong>two combined layers&lt;/strong>:&lt;/p>
&lt;h3 id="head-based-sampling-on-the-way-in">Head-based sampling (on the way in)&lt;/h3>
&lt;p>You decide &lt;strong>before&lt;/strong> the span exists whether you are going to trace it or not, based on a probabilistic decision (1-5 % typically) or on a deterministic rule (always trace if the user is premium, if the model is the new one in canary, and so on).&lt;/p>
&lt;p>Pros: cheap (the span is not generated at all), deterministic.&lt;/p>
&lt;p>Cons: &lt;strong>you can miss infrequent errors&lt;/strong>. If an error happens once in every 1,000 and you trace 1 %, you will see it once in every 100,000, which is no longer debuggable.&lt;/p>
&lt;h3 id="tail-based-sampling-on-the-way-out">Tail-based sampling (on the way out)&lt;/h3>
&lt;p>The complete span is generated and at the end you decide whether to keep it or throw it away. The decision can be:&lt;/p>
&lt;ul>
&lt;li>Keep 100 % of errors.&lt;/li>
&lt;li>Keep 100 % of traces with latency &amp;gt; X ms.&lt;/li>
&lt;li>Keep 100 % of traces that triggered a guardrail.&lt;/li>
&lt;li>Keep a random sample of the rest (1-5 %).&lt;/li>
&lt;/ul>
&lt;p>Pros: it guarantees that you see the errors and the latency outliers. Without it, the most interesting problems are the ones most likely to be left out.&lt;/p>
&lt;p>Cons: you need a buffer, because until the trace finishes you cannot decide. The Collector&amp;rsquo;s &lt;code>tail_sampling&lt;/code> processor keeps the spans in memory for 5-30 seconds until it takes the decision.&lt;/p>
&lt;p>The practical rule in 2026 is to &lt;strong>combine both&lt;/strong>: head-based at 5 % + tail-based capturing 100 % of errors and latencies &amp;gt; 5s. That gives you around 5 % of baseline traffic plus 100 % of what is interesting.&lt;/p>
&lt;h2 id="anatomy-of-a-real-trace">Anatomy of a real trace&lt;/h2>
&lt;p>We are going to take apart the trace of a single user question to a typical chat-with-RAG-and-tool system. The question is &amp;ldquo;what is the balance of my main account?&amp;rdquo;.&lt;/p>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Anatomy of a complete LLM trace">
&lt;style>
.tr{fill:#f4f4f4;stroke:#999;stroke-width:1}
.s1{fill:#d6eaff;stroke:#222;stroke-width:1}
.s2{fill:#ffe9d6;stroke:#222;stroke-width:1}
.s3{fill:#d9f5d6;stroke:#222;stroke-width:1}
.s4{fill:#fff5b0;stroke:#222;stroke-width:1}
.s5{fill:#e9d6f5;stroke:#222;stroke-width:1}
.lb{font:600 11px sans-serif;fill:#222}
.sm{font:400 10px sans-serif;fill:#444}
.h{font:700 13px sans-serif;fill:#222}
.tm{font:400 10px sans-serif;fill:#888}
&lt;/style>
&lt;text x="380" y="20" text-anchor="middle" class="h">Trace_id = abc123 · "what is the balance of my main account?"&lt;/text>
&lt;text x="380" y="38" text-anchor="middle" class="tm">total duration: 2,847 ms · 7 spans · 1 trace&lt;/text>
&lt;line x1="60" y1="55" x2="700" y2="55" stroke="#ccc"/>
&lt;text x="60" y="68" class="tm">0 ms&lt;/text>
&lt;text x="700" y="68" text-anchor="end" class="tm">2847 ms&lt;/text>
&lt;rect x="60" y="80" width="640" height="22" class="s1"/>
&lt;text x="70" y="96" class="lb">HTTP POST /chat — root span (app)&lt;/text>
&lt;rect x="80" y="112" width="100" height="20" class="s2"/>
&lt;text x="86" y="126" class="sm">guardrail.input · 38ms&lt;/text>
&lt;rect x="190" y="112" width="220" height="20" class="s3"/>
&lt;text x="196" y="126" class="sm">rag.retrieve · 218ms · BGE-M3 + Qdrant&lt;/text>
&lt;rect x="190" y="136" width="80" height="18" class="s3" opacity="0.7"/>
&lt;text x="195" y="149" class="sm">embed · 28ms&lt;/text>
&lt;rect x="280" y="136" width="125" height="18" class="s3" opacity="0.7"/>
&lt;text x="285" y="149" class="sm">vector_search · 80ms&lt;/text>
&lt;rect x="420" y="112" width="280" height="20" class="s4"/>
&lt;text x="426" y="126" class="lb">gen_ai.chat · 2104ms · llama-3.1-70b-instruct&lt;/text>
&lt;rect x="510" y="136" width="80" height="18" class="s5" opacity="0.85"/>
&lt;text x="515" y="149" class="sm">tool.get_balance · 60ms&lt;/text>
&lt;rect x="600" y="112" width="60" height="20" class="s2"/>
&lt;text x="606" y="126" class="sm">guardrail.output · 28ms&lt;/text>
&lt;line x1="60" y1="170" x2="700" y2="170" stroke="#ccc"/>
&lt;text x="60" y="190" class="h">Key attributes per span&lt;/text>
&lt;text x="60" y="208" class="sm">root: &lt;tspan font-family="monospace">http.method=POST, user.id=42, conversation.id=session_abc&lt;/tspan>&lt;/text>
&lt;text x="60" y="224" class="sm">guardrail.input: &lt;tspan font-family="monospace">gen_ai.tool.name=llm_guard, gen_ai.tool.type=guardrail, gen_ai.guardrail.decision=allow&lt;/tspan>&lt;/text>
&lt;text x="60" y="240" class="sm">rag.retrieve: &lt;tspan font-family="monospace">gen_ai.operation.name=embeddings, gen_ai.request.model=bge-m3, db.system=qdrant&lt;/tspan>&lt;/text>
&lt;text x="60" y="256" class="sm">vector_search: &lt;tspan font-family="monospace">db.operation=query, db.collection=docs_prod, db.qdrant.top_k=20, db.qdrant.score=0.83&lt;/tspan>&lt;/text>
&lt;text x="60" y="272" class="sm">gen_ai.chat: &lt;tspan font-family="monospace">gen_ai.system=vllm, request.model=llama-3.1-70b, usage.input=1247, usage.output=412&lt;/tspan>&lt;/text>
&lt;text x="60" y="288" class="sm"> prompt.id=customer_support_v3, prompt.version=14, prompt.label=production&lt;/text>
&lt;text x="60" y="304" class="sm">tool.get_balance: &lt;tspan font-family="monospace">gen_ai.tool.name=get_balance, gen_ai.tool.type=function, gen_ai.tool.call.id=call_xy&lt;/tspan>&lt;/text>
&lt;text x="60" y="320" class="sm">guardrail.output: &lt;tspan font-family="monospace">gen_ai.tool.name=llama_guard_4, gen_ai.guardrail.decision=allow&lt;/tspan>&lt;/text>
&lt;text x="60" y="344" class="sm" font-style="italic">All the spans share trace_id=abc123. Langfuse draws them as a tree; Tempo as a flat timeline.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Seven spans, one single trace. The information you can exploit:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Latency broken down&lt;/strong>: 38 ms guardrail + 218 ms retrieval + 2104 ms LLM + 60 ms tool + 28 ms guardrail = 2,448 ms (the rest is orchestration overhead). The bottleneck is the LLM (74 % of the time). If the user complains about slowness, you know where to look.&lt;/li>
&lt;li>&lt;strong>Tokens and cost&lt;/strong>: 1,247 input + 412 output. With Llama 3.1 70B on-prem at about 12 W/token of equivalent energy cost, roughly 3.5 c€ per response. Multiplied by volume, that gives you the inference bill.&lt;/li>
&lt;li>&lt;strong>&lt;code>prompt.id+version&lt;/code> travelling along&lt;/strong>: if in a month&amp;rsquo;s time you notice that quality has dropped, you filter by &lt;code>prompt.version&lt;/code> and see whether it coincides with a change to the prompt.&lt;/li>
&lt;li>&lt;strong>Guardrail decisions traced&lt;/strong>: if a guardrail blocked the response, it stays in the trace with &lt;code>gen_ai.guardrail.decision=block&lt;/code> and a reason. ENS audit satisfied.&lt;/li>
&lt;li>&lt;strong>Correlated tool calls&lt;/strong>: &lt;code>tool.get_balance&lt;/code> is a child span of &lt;code>gen_ai.chat&lt;/code>. If the tool failed, you see the error directly in its span; it does not turn up only in separate logs.&lt;/li>
&lt;/ul>
&lt;h2 id="implications-for-on-premise-hardware">Implications for on-premise hardware&lt;/h2>
&lt;p>Tracing is not free, but its overhead, properly sized, is negligible.&lt;/p>
&lt;h3 id="cost-per-span">Cost per span&lt;/h3>
&lt;p>At the SDK level, an LLM span adds around 50-200 µs of overhead (creation, attributes, serialisation). For an LLM call lasting 2 seconds, that is &amp;lt; 0.01 % of the latency.&lt;/p>
&lt;p>At the network level, a typical compressed OTLP span weighs around 1-3 KB. For a system at 100 req/s with 7 spans per request, that is 700 spans/s × 2 KB ≈ 1.4 MB/s to the Collector. Trivial.&lt;/p>
&lt;h3 id="storage">Storage&lt;/h3>
&lt;p>This is where the money goes. Without sampling, a month at 100 req/s is about 18 GB of spans. With head-based at 5 % + tail-based on errors, about 1-2 GB a month. Manageable in any self-hosted ClickHouse or Loki.&lt;/p>
&lt;h3 id="on-an-rtx-4090-24-gb--collector-and-langfuse-on-prem">On an RTX 4090 (24 GB) + Collector and Langfuse on-prem&lt;/h3>
&lt;p>For a demo service or a small single-tenant: Langfuse + Postgres + Clickhouse + Collector on the same host as the model. It uses around 8 GB of RAM and &amp;lt; 1 % of CPU continuously. The 4090 serves the model, the rest lives on the CPU. Minimum viable setup for a startup or a small team.&lt;/p>
&lt;h3 id="on-a-generic-4h100-sxm-cluster--central-observability">On a generic 4×H100 SXM cluster + central observability&lt;/h3>
&lt;p>A dedicated pod for the OTel Collector (a DaemonSet on each node + a central Gateway). Langfuse + ClickHouse + Tempo + Prometheus + Grafana in an &lt;code>observability&lt;/code> namespace separate from the &lt;code>serving&lt;/code> namespace. This is the canonical architecture of any serious ENS/NIS2 production deployment: observability does not share resources with the model.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>LLM metrics (Prometheus): the canonical counters and histograms&lt;/strong> — &lt;code>gen_ai_client_request_duration_seconds&lt;/code>, &lt;code>gen_ai_client_input_tokens_total&lt;/code> and the rest. How Grafana dashboards are built on that base.&lt;/li>
&lt;li>&lt;strong>Guardrails as spans&lt;/strong>: how to model Llama Guard 4, NeMo Guardrails and LLM Guard as child spans of the LLM span, and what attributes they carry. Covered in the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">post on guardrails&lt;/a> — attributes &lt;code>gen_ai.guardrail.line&lt;/code>, &lt;code>gen_ai.guardrail.detector&lt;/code>, &lt;code>gen_ai.guardrail.category&lt;/code>, &lt;code>gen_ai.guardrail.score&lt;/code>, &lt;code>gen_ai.guardrail.action&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Distributed tracing with MCP&lt;/strong> — &lt;code>traceparent&lt;/code> propagation from the LLM client to the MCP server, &lt;code>mcp.*&lt;/code> attributes, known problems.&lt;/li>
&lt;li>&lt;strong>eBPF for automatic tracing without an SDK&lt;/strong> — Tetragon and Hubble extracting LLM traces without explicit instrumentation, for cases where the code cannot be modified.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> — the &lt;code>prompt_id@version&lt;/code> that travels as &lt;code>gen_ai.prompt.id&lt;/code> in the spans described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs: the layer after tracing&lt;/a> — the &amp;ldquo;the judge runs over a sample of the traces&amp;rdquo; pattern rests on this pipeline.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge: the exam marker&lt;/a> — the judge consumes traces to evaluate quality continuously. The traces kept by tail-sampling are its input.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — traces are the origin of the fine-tuning dataset. When a user regenerates, that span with &lt;code>gen_ai.response.finish_reasons=[&amp;quot;stop&amp;quot;]&lt;/code> rejected enters the DPO pipeline.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where OBSERVE is stage 5.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — the online safety layer whose decisions are modelled as child spans of the LLM span with &lt;code>gen_ai.guardrail.*&lt;/code> attributes. The traceability of every block / redact / flag is the basis for ENS / NIS2 / EU AI Act audits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard: anatomy and integration with Langfuse&lt;/a> — the concrete tool whose OTel HTTP export described in this post closes the circle: &lt;code>gen_ai.guardrail.*&lt;/code> spans for each scanner executed, ingestable directly by Langfuse via &lt;code>/api/public/otel&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001: the operations manual for the AI system&lt;/a> — the OTel + Langfuse tracing described here materialises control A.8 (information to interested parties) of Annex A: per-request traceability, forensic reporting capability and incident notification to users and regulators.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">EU AI Act: the technical file article by article&lt;/a> — the OTel traceability described here is the canonical materialisation of Arts. 12 + 19 (record-keeping and automatic logs) of the Regulation; a minimum retention of 6 months + WORM + redacted PII are the explicit legal requirements.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls ENS × 42001 × EU AI Act&lt;/a> — OTel &lt;code>gen_ai.*&lt;/code> spans with the right metadata satisfy &lt;code>op.exp.8 + .10&lt;/code> ENS + A.8.2 ISO 42001 + Arts. 12 + 19 AI Act &lt;strong>in a single piece&lt;/strong> when they are labelled with a common vocabulary.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — the other half of LLM observability: the DCGM (hardware) and vLLM (engine) metrics complement the semantic tracing of &lt;code>gen_ai.*&lt;/code> to cover the questions &amp;ldquo;is the GPU healthy?&amp;rdquo; and &amp;ldquo;is it meeting the SLO?&amp;rdquo;.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Inside Langfuse: v3 architecture and the 10 backend knobs&lt;/a> — here Langfuse is the destination of the pipeline; there the box is opened: the six services (Web, Worker, Postgres, ClickHouse, Redis, S3), asynchronous ingestion and self-hosted tuning. The two-layer sampling in this post is the &amp;ldquo;knob 0&amp;rdquo; that multiplies the ten in that one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">Langfuse v4: what goes into a trace&lt;/a> — the precedence chain with which a real backend resolves these attributes, and the scores that do not travel over OTLP.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>OpenTelemetry GenAI Semantic Conventions: &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">https://opentelemetry.io/docs/specs/semconv/gen-ai/&lt;/a>.&lt;/li>
&lt;li>OpenLLMetry (Traceloop): &lt;a href="https://github.com/traceloop/openllmetry">https://github.com/traceloop/openllmetry&lt;/a>.&lt;/li>
&lt;li>openinference (Arize): &lt;a href="https://github.com/Arize-ai/openinference">https://github.com/Arize-ai/openinference&lt;/a>.&lt;/li>
&lt;li>Langfuse OTel integration: &lt;a href="https://langfuse.com/docs/opentelemetry/get-started">https://langfuse.com/docs/opentelemetry/get-started&lt;/a>.&lt;/li>
&lt;li>OTel Collector tail-sampling processor: &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor">https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor&lt;/a>.&lt;/li>
&lt;li>Phoenix (Arize) docs: &lt;a href="https://docs.arize.com/phoenix">https://docs.arize.com/phoenix&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol observability (&lt;code>mcp.*&lt;/code> semconv): &lt;a href="https://modelcontextprotocol.io/docs/observability">https://modelcontextprotocol.io/docs/observability&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>LLM-as-judge: the exam marker who grades other models without turning into an oracle</title><link>https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/</link><pubDate>Wed, 27 May 2026 08:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/</guid><description>&lt;blockquote>
&lt;p>This post goes deeper into the judges section of &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs&lt;/a>. There it was one piece of the mixed panel; here we get into why it works, where it breaks and how its calibration is measured before accepting it in CI.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An LLM judge is not &amp;ldquo;a GPT-4 you ask whether the answer is any good&amp;rdquo;. It is a &lt;strong>trained marker&lt;/strong>: it has a rubric written in advance, it is asked for explicit reasoning before the verdict, its score is computed as an expectation weighted by token probabilities (not as the first token it spits out), and before being accepted in production it is calibrated against a sample of ~50 human-annotated examples until it reaches &lt;strong>κ ≥ 0.5&lt;/strong> (Cohen&amp;rsquo;s kappa). The state of the art in May 2026 is three patterns, &lt;strong>G-Eval&lt;/strong>, &lt;strong>Prometheus 2&lt;/strong> and &lt;strong>panel of judges&lt;/strong>, each answering a different trade-off between cost, quality and reproducibility. All of them share four documented biases: position, verbosity, self-preference and narcissism. This post explains how a real judge is built, how to measure whether it lies, and when each pattern is appropriate.&lt;/p>
&lt;h2 id="you-are-here-eval">You are here: EVAL&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: Eval">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7aafff;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(#jdm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#jdm)}&lt;/style>
&lt;defs>&lt;marker id="jdm" 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: EVAL · the judge piece inside the mixed panel&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 active"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-civil-service-exam-marker">The analogy: the civil service exam marker&lt;/h2>
&lt;p>A public competitive exam has thousands of papers. There is no way the senior board can mark them all. The Spanish system&amp;rsquo;s answer has been the same for decades: &lt;strong>trained markers&lt;/strong>. People who are not professors, but who are given:&lt;/p>
&lt;ol>
&lt;li>A &lt;strong>marking template&lt;/strong> written in advance: what is valued, how many points each section is worth, what gets deducted.&lt;/li>
&lt;li>&lt;strong>Prior training&lt;/strong> with a sample of papers already marked by the senior board, until their marks agree reasonably well.&lt;/li>
&lt;li>&lt;strong>Continuous auditing&lt;/strong>: a fraction of their marking is re-marked by the senior board to verify that the marker is not drifting.&lt;/li>
&lt;/ol>
&lt;p>A well-built LLM judge is exactly that. &lt;strong>It is not an oracle, it is a trained marker&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>The rubric is the explicit criteria in the prompt (what faithfulness is, what relevancy is, and so on).&lt;/li>
&lt;li>The training is the calibration against ~50 human-annotated examples.&lt;/li>
&lt;li>The continuous auditing is the weekly sampling where a human re-evaluates a fraction of the traffic the judge has judged.&lt;/li>
&lt;/ul>
&lt;p>And, as with the human marker, &lt;strong>the judge has systematic biases&lt;/strong> that the public exam system has learned to watch for: markers prefer certain handwriting styles, certain lengths, certain structures. The same happens to the judge. The rest of this post takes apart exactly which ones and how they are measured.&lt;/p>
&lt;h2 id="why-llm-as-judge-exists">Why LLM-as-judge exists&lt;/h2>
&lt;p>The direct reason: money and time. A professional human annotation costs on the order of 0.50 € to 5 € per example (depending on complexity and domain), and takes 30 seconds to several minutes. A GPT-4 judgement costs ~0.01-0.05 € and takes ~2 seconds. For a golden dataset of 500 examples evaluated continuously over 10 candidates a day, &lt;strong>the difference is between 2,500 € a day and 50 € a day&lt;/strong>. And the wall-clock difference is between days and minutes.&lt;/p>
&lt;p>The less direct but more relevant reason: methodological scalability. A golden dataset of 500 examples is relatively easy to annotate once. What happens afterwards is the hard part:&lt;/p>
&lt;ul>
&lt;li>Every time a candidate adapter appears, all 500 have to be re-evaluated.&lt;/li>
&lt;li>Every time the dataset is updated (because a new incident came in) it has to be re-evaluated.&lt;/li>
&lt;li>Every time the system prompt changes it has to be re-evaluated.&lt;/li>
&lt;/ul>
&lt;p>Without an automatable, cheap judge, the eval battery stops running and the system goes blind between releases. That is what really justifies the pattern.&lt;/p>
&lt;h2 id="the-three-canonical-patterns-in-2026">The three canonical patterns in 2026&lt;/h2>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three LLM-as-judge patterns">
&lt;style>
.bx{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.b1{fill:#ffe6d6}
.b2{fill:#d6eaff}
.b3{fill:#d9f5d6}
.bh{fill:#fff5b0;stroke:#444;stroke-width:1.4;rx:6}
.t{font:700 13px sans-serif;fill:#222}
.s{font:400 11px sans-serif;fill:#555}
.h{font:700 14px sans-serif;fill:#222}
.ar{stroke:#666;stroke-width:1.5;fill:none;marker-end:url(#mjj)}
&lt;/style>
&lt;defs>&lt;marker id="mjj" 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="380" y="22" text-anchor="middle" class="h">Three canonical patterns for building a judge&lt;/text>
&lt;rect x="30" y="45" width="220" height="240" class="bx b1"/>
&lt;text x="140" y="68" text-anchor="middle" class="t">1 · G-Eval&lt;/text>
&lt;text x="140" y="86" text-anchor="middle" class="s">(Liu et al. 2023)&lt;/text>
&lt;rect x="50" y="100" width="180" height="40" class="bh"/>
&lt;text x="140" y="118" text-anchor="middle" class="s">Strong judge (GPT-4o, Claude)&lt;/text>
&lt;text x="140" y="132" text-anchor="middle" class="s">+ rubric + CoT + form-filling&lt;/text>
&lt;text x="50" y="158" class="s">• score = E[i · P(token=i)]&lt;/text>
&lt;text x="50" y="174" class="s">• prompt with criterion + examples&lt;/text>
&lt;text x="50" y="190" class="s">• structured output (JSON)&lt;/text>
&lt;text x="50" y="206" class="s">• high cost, high quality&lt;/text>
&lt;text x="50" y="226" class="s">↗ cases:&lt;/text>
&lt;text x="50" y="242" class="s"> small golden set + domain&lt;/text>
&lt;text x="50" y="258" class="s"> where quality matters more&lt;/text>
&lt;text x="50" y="274" class="s"> than cost per judgement&lt;/text>
&lt;rect x="270" y="45" width="220" height="240" class="bx b2"/>
&lt;text x="380" y="68" text-anchor="middle" class="t">2 · Prometheus 2&lt;/text>
&lt;text x="380" y="86" text-anchor="middle" class="s">(Kim et al. 2024)&lt;/text>
&lt;rect x="290" y="100" width="180" height="40" class="bh"/>
&lt;text x="380" y="118" text-anchor="middle" class="s">Specialised open source judge&lt;/text>
&lt;text x="380" y="132" text-anchor="middle" class="s">Mistral 8×7B fine-tuned&lt;/text>
&lt;text x="290" y="158" class="s">• score 1-5 on a custom rubric&lt;/text>
&lt;text x="290" y="174" class="s">• 0.897 correlation with GPT-4&lt;/text>
&lt;text x="290" y="190" class="s">• runs on-premise (~32 GB VRAM)&lt;/text>
&lt;text x="290" y="206" class="s">• no external cost per judgement&lt;/text>
&lt;text x="290" y="226" class="s">↗ cases:&lt;/text>
&lt;text x="290" y="242" class="s"> mass on-prem evaluation,&lt;/text>
&lt;text x="290" y="258" class="s"> data stays inside the perimeter,&lt;/text>
&lt;text x="290" y="274" class="s"> strict ENS/NIS2&lt;/text>
&lt;rect x="510" y="45" width="220" height="240" class="bx b3"/>
&lt;text x="620" y="68" text-anchor="middle" class="t">3 · Panel of Judges&lt;/text>
&lt;text x="620" y="86" text-anchor="middle" class="s">(Verga et al. 2024)&lt;/text>
&lt;rect x="530" y="100" width="180" height="40" class="bh"/>
&lt;text x="620" y="118" text-anchor="middle" class="s">3-5 heterogeneous judges&lt;/text>
&lt;text x="620" y="132" text-anchor="middle" class="s">+ aggregation (median/vote)&lt;/text>
&lt;text x="530" y="158" class="s">• reduces self-preference bias&lt;/text>
&lt;text x="530" y="174" class="s">• lower variance than single&lt;/text>
&lt;text x="530" y="190" class="s">• 3-5× higher cost&lt;/text>
&lt;text x="530" y="206" class="s">• flags disputed cases&lt;/text>
&lt;text x="530" y="226" class="s">↗ cases:&lt;/text>
&lt;text x="530" y="242" class="s"> critical gate decisions,&lt;/text>
&lt;text x="530" y="258" class="s"> evaluating models from the&lt;/text>
&lt;text x="530" y="274" class="s"> same vendor (self-judging)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="1--g-eval">1 · G-Eval&lt;/h3>
&lt;p>Published by Liu et al. (2023). The base idea is so simple it fits in one sentence: &lt;strong>give the judge a detailed rubric, ask it to reason before scoring, and read the result as a weighted expectation instead of as the first token&lt;/strong>. The three levers:&lt;/p>
&lt;p>&lt;strong>Rubric:&lt;/strong> a prompt with an explicit criterion (e.g. &amp;ldquo;Faithfulness: the degree to which the answer relies only on the context, without inventing data. Score 1 = invents everything, 5 = everything supported by the context&amp;rdquo;), ideally with one or two examples per extreme value.&lt;/p>
&lt;p>&lt;strong>Chain-of-thought + form-filling:&lt;/strong> the model is first asked to &amp;ldquo;reason briefly about each criterion&amp;rdquo; and then to &amp;ldquo;fill in this JSON form&amp;rdquo;. That forces it not to spit out an arbitrary number.&lt;/p>
&lt;p>&lt;strong>Probability-weighted scoring:&lt;/strong> instead of reading the first token after the &lt;code>score:&lt;/code> field, you look at the model&amp;rsquo;s probability distribution over the tokens &lt;code>1, 2, 3, 4, 5&lt;/code> and compute:&lt;/p>
$$\hat{s} = \sum_{i=1}^{5} i \cdot \frac{p(\text{token}=i)}{\sum_{j=1}^{5} p(\text{token}=j)}$$
&lt;p>This turns a discrete score into a continuous one. The justification: if the judge was &amp;ldquo;torn&amp;rdquo; between 4 and 5 (probabilities 0.4 and 0.5 on &lt;code>4&lt;/code> and &lt;code>5&lt;/code>), the real score is 4.55, not &lt;code>5&lt;/code>. This sharply reduces variance across runs of the same prompt, and captures information that greedy decoding throws away.&lt;/p>
&lt;p>Limitations of G-Eval: &lt;strong>it needs access to the model&amp;rsquo;s logprobs&lt;/strong>. Closed-source models have been restricting that access (Claude does not expose it, GPT-4 does but only top-5). In 2026 G-Eval with strict probability weighting is only practical against open source models you serve yourself (vLLM exposes them) or against GPT-4 with &lt;code>logprobs=true&lt;/code>.&lt;/p>
&lt;h3 id="2--prometheus-2">2 · Prometheus 2&lt;/h3>
&lt;p>Published by Kim et al. (KAIST, 2024). The insight is complementary to G-Eval: &lt;strong>what if, instead of asking a generalist judge to evaluate, we fine-tune a specific judge?&lt;/strong>&lt;/p>
&lt;p>Prometheus 2 is a Mistral 8×7B (MoE, ~47 GB in BF16, ~24 GB in INT4) fine-tuned on 100k+ evaluation examples with varied rubrics. The metric published in the paper: &lt;strong>0.897 Pearson correlation with GPT-4-as-judge&lt;/strong> on the Vicuna Bench and similar. That matters because it means GPT-4 can be replaced as a judge at the cost of local inference without losing almost any quality.&lt;/p>
&lt;p>Why it matters in on-premise production:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>No data leaves the perimeter&lt;/strong>. For strict ENS/NIS2 customers this is not a preference, it is a requirement. A judge that travels over an external API is not an option there.&lt;/li>
&lt;li>&lt;strong>Zero marginal cost&lt;/strong>. When the judge runs on-prem, evaluating 50,000 cases a day adds no external bill.&lt;/li>
&lt;li>&lt;strong>Controlled latency&lt;/strong>. Continuous eval over real traffic can run in parallel without saturating an external provider&amp;rsquo;s rate limits.&lt;/li>
&lt;/ul>
&lt;p>The price: you have to maintain one more inference service (Prometheus 2 running in its own vLLM), and the judge does not &amp;ldquo;update&amp;rdquo; unless it is re-fine-tuned.&lt;/p>
&lt;h3 id="3--panel-of-judges">3 · Panel of Judges&lt;/h3>
&lt;p>Verga et al. (Cohere, 2024) formalised it: instead of a single judge, use &lt;strong>3-5 heterogeneous judges&lt;/strong>, different models, different prompts, different temperatures, and aggregate their judgements.&lt;/p>
&lt;p>Common aggregation mechanisms:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Median&lt;/strong> for continuous scores. Robust to outliers.&lt;/li>
&lt;li>&lt;strong>Majority vote&lt;/strong> for pairwise judgements (chosen vs rejected). If 3 of 5 prefer A, the winner is A.&lt;/li>
&lt;li>&lt;strong>Calibration-weighted mean&lt;/strong>: weight each judge by its κ against humans in calibration. The more reliable judges vote more.&lt;/li>
&lt;/ul>
&lt;p>What a panel gives that a single judge does not:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Reduction of self-preference&lt;/strong> (a judge&amp;rsquo;s bias towards outputs stylistically similar to its own): if the judges come from different vendors, the sum cancels out.&lt;/li>
&lt;li>&lt;strong>A measure of case difficulty&lt;/strong>: if all 5 judges agree, the case is easy; if they are split, the case is ambiguous and should be escalated to a human. That turns the panel into an automatic triaging system for human annotation.&lt;/li>
&lt;li>&lt;strong>Lower variance&lt;/strong>: each judge&amp;rsquo;s noise is averaged out.&lt;/li>
&lt;/ol>
&lt;p>The cost: 3-5× the bill of a single judge. That is why the panel is usually reserved for &lt;strong>critical eval gates&lt;/strong> (does this adapter get promoted or not?), not for continuous eval over traffic.&lt;/p>
&lt;h2 id="how-to-measure-whether-the-judge-lies-cohens-kappa">How to measure whether the judge lies: Cohen&amp;rsquo;s kappa&lt;/h2>
&lt;p>Accepting the judge in production without measuring its agreement with humans is the same as accepting an uncalibrated thermometer. The standard metric for inter-rater agreement with discrete or ordinal scales is &lt;strong>Cohen&amp;rsquo;s kappa&lt;/strong>:&lt;/p>
$$\kappa = \frac{p_o - p_e}{1 - p_e}$$
&lt;p>Where &lt;code>p_o&lt;/code> is the &lt;strong>observed agreement proportion&lt;/strong> (what percentage of examples judge and human agree on) and &lt;code>p_e&lt;/code> is the &lt;strong>agreement proportion expected by chance&lt;/strong> (what you would expect if both scored at random respecting each one&amp;rsquo;s marginals).&lt;/p>
&lt;p>The intuition: if &lt;code>p_o = 0.9&lt;/code> but &lt;code>p_e = 0.85&lt;/code> (because both nearly always score &amp;ldquo;4 or 5&amp;rdquo;), agreement is 90 % in raw terms but κ = 0.33: most of the agreement comes from both scoring high, not from them understanding each other. κ corrects for that baseline.&lt;/p>
&lt;p>The usual interpretive scale (Landis and Koch 1977, still the reference):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>κ&lt;/th>
&lt;th>Interpretation&lt;/th>
&lt;th>Production threshold&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&amp;lt; 0.20&lt;/td>
&lt;td>Poor&lt;/td>
&lt;td>Useless&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.21–0.40&lt;/td>
&lt;td>Fair&lt;/td>
&lt;td>Weak signal only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.41–0.60&lt;/td>
&lt;td>Moderate&lt;/td>
&lt;td>&lt;strong>Minimum acceptable in 2026&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.61–0.80&lt;/td>
&lt;td>Substantial&lt;/td>
&lt;td>State of the art for open source judges&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.81–1.00&lt;/td>
&lt;td>Almost perfect&lt;/td>
&lt;td>Human judges rarely reach it between themselves&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>An important point the field learned the hard way: &lt;strong>humans between themselves rarely exceed κ = 0.70&lt;/strong> on LLM tasks (faithfulness, relevancy). That is the realistic ceiling for an LLM judge. Chasing κ = 0.9 against humans is chasing a ghost: not even two human annotators get there.&lt;/p>
&lt;h3 id="weighted-kappa-for-ordinal-scales">Weighted kappa for ordinal scales&lt;/h3>
&lt;p>For 1-5 scores, the disagreement &amp;ldquo;judge says 4, human says 5&amp;rdquo; is not the same as &amp;ldquo;judge says 1, human says 5&amp;rdquo;. Standard kappa treats both as an identical failure. &lt;strong>Linear or quadratic weighted kappa&lt;/strong> assigns weight to the magnitude of the disagreement:&lt;/p>
$$\kappa_w = 1 - \frac{\sum_{i,j} w_{ij} \, o_{ij}}{\sum_{i,j} w_{ij} \, e_{ij}}, \quad w_{ij}^{(\text{lin})} = \frac{|i-j|}{k-1}, \quad w_{ij}^{(\text{quad})} = \frac{(i-j)^2}{(k-1)^2}.$$
&lt;p>In G-Eval with 1-5 scores, the usual practice is to publish &lt;code>κ_quad&lt;/code> because it penalises large disagreements more and comes closer to human intuition.&lt;/p>
&lt;h3 id="numerical-calibration-example">Numerical calibration example&lt;/h3>
&lt;p>Imagine a golden set of 50 examples scored by a human and by the judge, both on a 1-5 scale. The confusion matrix:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Hum 1&lt;/th>
&lt;th>Hum 2&lt;/th>
&lt;th>Hum 3&lt;/th>
&lt;th>Hum 4&lt;/th>
&lt;th>Hum 5&lt;/th>
&lt;th>Judge total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Judge 1&lt;/strong>&lt;/td>
&lt;td>3&lt;/td>
&lt;td>1&lt;/td>
&lt;td>0&lt;/td>
&lt;td>0&lt;/td>
&lt;td>0&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Judge 2&lt;/strong>&lt;/td>
&lt;td>1&lt;/td>
&lt;td>4&lt;/td>
&lt;td>2&lt;/td>
&lt;td>0&lt;/td>
&lt;td>0&lt;/td>
&lt;td>7&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Judge 3&lt;/strong>&lt;/td>
&lt;td>0&lt;/td>
&lt;td>1&lt;/td>
&lt;td>6&lt;/td>
&lt;td>2&lt;/td>
&lt;td>0&lt;/td>
&lt;td>9&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Judge 4&lt;/strong>&lt;/td>
&lt;td>0&lt;/td>
&lt;td>0&lt;/td>
&lt;td>1&lt;/td>
&lt;td>12&lt;/td>
&lt;td>3&lt;/td>
&lt;td>16&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Judge 5&lt;/strong>&lt;/td>
&lt;td>0&lt;/td>
&lt;td>0&lt;/td>
&lt;td>0&lt;/td>
&lt;td>2&lt;/td>
&lt;td>12&lt;/td>
&lt;td>14&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Human total&lt;/strong>&lt;/td>
&lt;td>4&lt;/td>
&lt;td>6&lt;/td>
&lt;td>9&lt;/td>
&lt;td>16&lt;/td>
&lt;td>15&lt;/td>
&lt;td>50&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Diagonal (exact agreements): 3+4+6+12+12 = 37 → p_o = 0.74.&lt;/p>
&lt;p>&lt;code>p_e&lt;/code> is computed as Σ_i (n_judge_i · n_hum_i) / n² = (4·4 + 7·6 + 9·9 + 16·16 + 14·15) / 50² = (16+42+81+256+210) / 2500 = 605/2500 ≈ 0.242.&lt;/p>
$$\kappa = \frac{0.74 - 0.242}{1 - 0.242} = \frac{0.498}{0.758} \approx 0.66$$
&lt;p>Substantial. Acceptable. If we wanted quadratic weighted κ, near disagreements (Judge 4 vs Hum 5) weigh less than distant ones (Judge 2 vs Hum 4), and κ_quad typically comes out 0.05-0.10 above the linear one.&lt;/p>
&lt;h2 id="the-four-biases-of-the-judge">The four biases of the judge&lt;/h2>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Four biases of the LLM judge">
&lt;style>
.bx{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.b1{fill:#ffe9d6}
.b2{fill:#d6eaff}
.b3{fill:#d9f5d6}
.b4{fill:#fff5b0}
.t{font:700 13px sans-serif;fill:#222}
.s{font:400 11px sans-serif;fill:#555}
.m{font:600 11px sans-serif;fill:#2c3e50}
.h{font:700 14px sans-serif;fill:#222}
&lt;/style>
&lt;text x="380" y="22" text-anchor="middle" class="h">Four documented biases of the LLM judge&lt;/text>
&lt;rect x="20" y="40" width="170" height="160" class="bx b1"/>
&lt;text x="105" y="62" text-anchor="middle" class="t">Position bias&lt;/text>
&lt;text x="30" y="84" class="s">Prefers the first answer&lt;/text>
&lt;text x="30" y="100" class="s">when both are comparable.&lt;/text>
&lt;text x="30" y="124" class="m">Measurement:&lt;/text>
&lt;text x="30" y="140" class="s">swap the order → fraction&lt;/text>
&lt;text x="30" y="156" class="s">of flips. &amp;lt;10% acceptable.&lt;/text>
&lt;text x="30" y="180" class="m">Fix: 2 swapped passes&lt;/text>
&lt;rect x="200" y="40" width="170" height="160" class="bx b2"/>
&lt;text x="285" y="62" text-anchor="middle" class="t">Verbosity bias&lt;/text>
&lt;text x="210" y="84" class="s">Rewards longer answers,&lt;/text>
&lt;text x="210" y="100" class="s">regardless of content.&lt;/text>
&lt;text x="210" y="124" class="m">Measurement:&lt;/text>
&lt;text x="210" y="140" class="s">Pearson corr score vs length&lt;/text>
&lt;text x="210" y="156" class="s">on golden. |r| &amp;lt; 0.3 acceptable.&lt;/text>
&lt;text x="210" y="180" class="m">Fix: explicit rubric&lt;/text>
&lt;rect x="380" y="40" width="170" height="160" class="bx b3"/>
&lt;text x="465" y="62" text-anchor="middle" class="t">Self-preference&lt;/text>
&lt;text x="390" y="84" class="s">A GPT-4 judge prefers&lt;/text>
&lt;text x="390" y="100" class="s">GPT-4-style outputs.&lt;/text>
&lt;text x="390" y="124" class="m">Measurement:&lt;/text>
&lt;text x="390" y="140" class="s">compare same dataset with&lt;/text>
&lt;text x="390" y="156" class="s">3 judges; divergence &amp;lt; 15%.&lt;/text>
&lt;text x="390" y="180" class="m">Fix: heterogeneous panel&lt;/text>
&lt;rect x="560" y="40" width="180" height="160" class="bx b4"/>
&lt;text x="650" y="62" text-anchor="middle" class="t">Narcissism&lt;/text>
&lt;text x="570" y="84" class="s">Evaluated model and judge&lt;/text>
&lt;text x="570" y="100" class="s">share architecture/vendor.&lt;/text>
&lt;text x="570" y="124" class="m">Measurement:&lt;/text>
&lt;text x="570" y="140" class="s">δ human vs judge score when&lt;/text>
&lt;text x="570" y="156" class="s">candidate and judge match.&lt;/text>
&lt;text x="570" y="180" class="m">Fix: external judge&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="position-bias">Position bias&lt;/h3>
&lt;p>First documented by Wang et al. (2023). If you present two answers A and B to the judge in a pairwise setting, &lt;strong>it prefers A more often than B&lt;/strong>, on the order of 55-65 % when A and B are objectively equivalent, with typical 2023-2024 judges. In 2026 frontier judges (GPT-5, Claude 4.5, Llama 4 Judge) have it fairly well mitigated, but &lt;strong>it is still measurable&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>How to measure it formally:&lt;/strong> run the dataset twice, once with &lt;code>(A, B)&lt;/code> and once with &lt;code>(B, A)&lt;/code>. If the judge is consistent, the two passes should agree. The fraction of cases where the verdict flips is the position-bias rate. Accepted threshold in 2026: &amp;lt; 10 %.&lt;/p>
&lt;p>&lt;strong>Canonical mitigation:&lt;/strong> always run two passes with the order reversed and average. Frameworks such as Promptfoo and Inspect AI do it by default.&lt;/p>
&lt;h3 id="verbosity-bias">Verbosity bias&lt;/h3>
&lt;p>Documented by Saito et al. and Dubois et al. in 2024. For open-ended tasks (faithfulness, helpfulness), the judge tends to give a higher score to longer answers. The typical Pearson correlation between score and length on answers of equivalent human-rated quality can rise to 0.4-0.5 without mitigation.&lt;/p>
&lt;p>&lt;strong>How to measure it:&lt;/strong> Pearson correlation between the judge&amp;rsquo;s score and the length of the answer, &lt;strong>computed over a subset where humans have confirmed equivalent quality&lt;/strong>. If the correlation is &amp;gt; 0.3 on that controlled subset, there is significant verbosity bias.&lt;/p>
&lt;p>&lt;strong>Mitigation:&lt;/strong> a rubric explicitly neutral on length (&amp;ldquo;the answer should be appropriately concise for the question; length is not a criterion&amp;rdquo;) and few-shot examples where a short answer beats a long one. AlpacaEval 2.0 builds a length correction directly into the metric.&lt;/p>
&lt;h3 id="self-preference-bias">Self-preference bias&lt;/h3>
&lt;p>Documented by Panickssery et al. (Anthropic, 2024). &lt;strong>A GPT-4 judge prefers GPT-4 outputs. A Claude judge prefers Claude outputs.&lt;/strong> Not out of conspiracy, but because models share stylistic patterns with their close relatives (paragraph structure, use of bullet points, tone).&lt;/p>
&lt;p>&lt;strong>How to measure it:&lt;/strong> over a golden set, compare the scores of 3 different judges (e.g. GPT-4, Claude, Llama 4 Judge). If for the same candidate there is &amp;gt; 15 % systematic divergence attributable to the candidate&amp;rsquo;s identity, there is self-preference.&lt;/p>
&lt;p>&lt;strong>Mitigation:&lt;/strong> a panel of judges with heterogeneous vendors. If a single judge is going to be used, &lt;strong>it must not come from the same vendor as the model being evaluated&lt;/strong>.&lt;/p>
&lt;h3 id="narcissism">Narcissism&lt;/h3>
&lt;p>The extreme case of self-preference: the judge &lt;strong>is exactly the same model&lt;/strong> as the candidate. This happens more than it seems: a team trains a Llama 3 8B with LoRA and evaluates it with Llama 3 8B as the judge because &amp;ldquo;it is what they have on-prem&amp;rdquo;. It is methodologically invalid. The delta between human score and judge score grows measurably.&lt;/p>
&lt;p>&lt;strong>Mitigation:&lt;/strong> a judge of a &lt;strong>different architecture&lt;/strong> from the candidate. If your candidate is Llama 3, your judge should be Mistral, Qwen or a Prometheus 2 (which, although based on Mistral, was fine-tuned specifically for evaluation).&lt;/p>
&lt;h2 id="the-judge-as-the-pipelines-hinge">The judge as the pipeline&amp;rsquo;s hinge&lt;/h2>
&lt;p>The judge is not just &amp;ldquo;the eval piece&amp;rdquo;. It is &lt;strong>the hinge&lt;/strong> that connects three stages of the pipeline:&lt;/p>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The judge as a hinge between Eval, Tune and Retrain">
&lt;style>
.bx{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.bt{fill:#ffd6d6}
.be{fill:#d6eaff}
.br{fill:#fff5b0}
.bj{fill:#d9f5d6;stroke:#444;stroke-width:2;rx:8}
.t{font:700 13px sans-serif;fill:#222}
.s{font:400 11px sans-serif;fill:#555}
.h{font:700 14px sans-serif;fill:#222}
.ar{stroke:#666;stroke-width:1.6;fill:none;marker-end:url(#mjbi)}
.ag{stroke:#27ae60;stroke-width:1.8;fill:none;marker-end:url(#mjbig)}
&lt;/style>
&lt;defs>
&lt;marker id="mjbi" 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="mjbig" 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="#27ae60"/>&lt;/marker>
&lt;/defs>
&lt;text x="380" y="22" text-anchor="middle" class="h">The judge as a hinge: produces preference pairs, decides gates, triggers retrain&lt;/text>
&lt;rect x="290" y="100" width="180" height="80" class="bj"/>
&lt;text x="380" y="130" text-anchor="middle" class="t">LLM judge&lt;/text>
&lt;text x="380" y="150" text-anchor="middle" class="s">G-Eval / Prometheus 2 /&lt;/text>
&lt;text x="380" y="166" text-anchor="middle" class="s">Panel of Judges&lt;/text>
&lt;rect x="30" y="60" width="170" height="60" class="bx bt"/>
&lt;text x="115" y="82" text-anchor="middle" class="t">TUNE&lt;/text>
&lt;text x="115" y="102" text-anchor="middle" class="s">DPO / KTO / ORPO / SimPO&lt;/text>
&lt;text x="115" y="116" text-anchor="middle" class="s">needs chosen/rejected pairs&lt;/text>
&lt;rect x="30" y="160" width="170" height="60" class="bx be"/>
&lt;text x="115" y="182" text-anchor="middle" class="t">EVAL gate (CI)&lt;/text>
&lt;text x="115" y="202" text-anchor="middle" class="s">does the adapter promote?&lt;/text>
&lt;text x="115" y="216" text-anchor="middle" class="s">faithfulness ≥ 0.85, regr &amp;lt; 2pp&lt;/text>
&lt;rect x="560" y="60" width="170" height="60" class="bx be"/>
&lt;text x="645" y="82" text-anchor="middle" class="t">Continuous EVAL&lt;/text>
&lt;text x="645" y="102" text-anchor="middle" class="s">sampling over real traffic,&lt;/text>
&lt;text x="645" y="116" text-anchor="middle" class="s">drift and regression detection&lt;/text>
&lt;rect x="560" y="160" width="170" height="60" class="bx br"/>
&lt;text x="645" y="182" text-anchor="middle" class="t">RETRAIN&lt;/text>
&lt;text x="645" y="202" text-anchor="middle" class="s">incidents → dataset enrichment&lt;/text>
&lt;text x="645" y="216" text-anchor="middle" class="s">judge classifies + triages&lt;/text>
&lt;path class="ag" d="M290,135 L200,85"/>
&lt;text x="220" y="115" class="s" fill="#27ae60">pairs&lt;/text>
&lt;path class="ag" d="M290,150 L200,180"/>
&lt;text x="220" y="160" class="s" fill="#27ae60">gate&lt;/text>
&lt;path class="ar" d="M470,135 L560,85"/>
&lt;text x="520" y="100" class="s">scores&lt;/text>
&lt;path class="ar" d="M470,150 L560,180"/>
&lt;text x="525" y="160" class="s">triage&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>&lt;strong>Towards TUNE:&lt;/strong> the judge generates the &lt;code>(chosen, rejected)&lt;/code> pairs for DPO without needing human labellers. That chain is what makes &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning&lt;/a> work without a dedicated annotation team.&lt;/p>
&lt;p>&lt;strong>Towards the EVAL gate:&lt;/strong> the judge gives the score that is compared against the CI threshold. If the adapter does not clear 0.85 on faithfulness, no merge.&lt;/p>
&lt;p>&lt;strong>Towards continuous EVAL:&lt;/strong> over a sample of real traffic (1-5 %), the judge computes scores and persists them. That makes it possible to detect regressions that appear days after deployment and that CI did not see because its golden set did not cover them.&lt;/p>
&lt;p>&lt;strong>Towards RETRAIN:&lt;/strong> the cases where the judge gives a low score are automatic candidates for the next retraining dataset. The judge acts as the triage of the incident flow.&lt;/p>
&lt;h2 id="implications-for-on-premise-hardware">Implications for on-premise hardware&lt;/h2>
&lt;p>The numbers below are indicative for typical scenarios in May 2026.&lt;/p>
&lt;h3 id="on-an-rtx-4090-24-gb">On an RTX 4090 (24 GB)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Judge&lt;/th>
&lt;th>Does it fit?&lt;/th>
&lt;th>Approximate throughput&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GPT-4o (API)&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>~50-100 judgements/min&lt;/td>
&lt;td>External cost, not local&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Prometheus 2 (8×7B INT4)&lt;/td>
&lt;td>Yes, just about&lt;/td>
&lt;td>~40-80 judgements/min&lt;/td>
&lt;td>Q4_K_M GGUF, llama.cpp&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 3.1 8B fine-tuned judge&lt;/td>
&lt;td>Yes, comfortably&lt;/td>
&lt;td>~150-250 judgements/min&lt;/td>
&lt;td>Reasonable on-prem default&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mistral Small Judge 22B&lt;/td>
&lt;td>Not directly, requires offload&lt;/td>
&lt;td>~10-20 judgements/min&lt;/td>
&lt;td>Too much for 24 GB in BF16&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Conclusion: on a single 4090, an open source 8B judge fine-tuned for evaluation (or a quantised Prometheus 2) is the sweet spot.&lt;/p>
&lt;h3 id="on-a-generic-4h100-sxm-cluster-320-gb-nvlink">On a generic 4×H100 SXM cluster (320 GB, NVLink)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Judge&lt;/th>
&lt;th>Configuration&lt;/th>
&lt;th>Approximate throughput&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Prometheus 2 BF16&lt;/td>
&lt;td>TP=2&lt;/td>
&lt;td>~400-700 judgements/min&lt;/td>
&lt;td>Fits comfortably, low latency&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 3.3 70B Instruct&lt;/td>
&lt;td>TP=4&lt;/td>
&lt;td>~150-300 judgements/min&lt;/td>
&lt;td>If used as a generalist judge&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Panel of 3 judges in parallel&lt;/td>
&lt;td>TP=1-2 each&lt;/td>
&lt;td>~600-1200 judgements/min combined&lt;/td>
&lt;td>Natural pattern in a cluster&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>On an NVLink cluster the natural approach is to &lt;strong>run a panel of judges&lt;/strong> in parallel (each judge taking 1-2 GPUs) with a LiteLLM router in front. That removes the cognitive cost of &amp;ldquo;which judge do we use&amp;rdquo; because all three are used and the result is aggregated.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Red teaming and safety eval&lt;/strong>: how robustness against adversarial prompts is evaluated. It is a different pattern from the ordinal judge.&lt;/li>
&lt;li>&lt;strong>Multistep agent eval&lt;/strong>: AgentBench, TauBench, evaluating trajectories instead of individual outputs.&lt;/li>
&lt;li>&lt;strong>Benchmark contamination&lt;/strong>: how to detect whether the evaluated model saw the golden set during pre-training, and why public benchmarks are half broken.&lt;/li>
&lt;li>&lt;strong>Cost-aware judging&lt;/strong>: when a cheap judge (Llama 8B) beats an expensive one (GPT-4o), and how to quantify the quality/cost trade-off with Pareto curves.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs: the layer after tracing&lt;/a> — the full context of the Eval stage; this post goes deeper into the judge piece inside the mixed panel described there.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO and SimPO&lt;/a> — the preference optimization methods that consume the pairs the judge produces.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — the Postgres + queries + hot-swap cycle that needs the judge as its hinge.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> — the judge&amp;rsquo;s prompt is versioned too; a &amp;ldquo;minor&amp;rdquo; change to the rubric can invalidate the calibration.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the full OTel pipeline that captures the traces the judge runs its continuous scoring over. The eval-tracing coupling closes there.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output&lt;/a> — the judge produces a structured verdict (&lt;code>{score, reasoning, decision}&lt;/code>); with constrained decoding (XGrammar) parsing is guaranteed not to fail, which removes retries and reduces the variance attributable to format failures.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> — the judge&amp;rsquo;s quality score is one of the five regression metrics acting as a gate in the canary; the detail of why it works offline but not inline is there.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evaluating-rag-ragas-golden-dataset-metrics/">Evaluating a RAG without fooling yourself: RAGAS, the golden dataset and the four metrics that matter&lt;/a> — RAGAS uses LLM-as-judge to measure faithfulness claim by claim; the judge in this post is exactly the piece RAGAS instantiates to classify whether each statement in the answer is supported by the retrieved chunks.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-quality-benchmarks-contamination-trap-oss-tools/">LLM quality benchmarks: MMLU, MT-Bench, HELM and the leaderboards that matter&lt;/a> — the public benchmarks that give an external perspective on whether an internal judge is calibrated with the community consensus.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Liu, Y., Iter, D., Xu, Y., Wang, S., Xu, R., Zhu, C. &lt;em>G-Eval: NLG Evaluation using GPT-4 with Better Human Alignment&lt;/em> (EMNLP 2023).&lt;/li>
&lt;li>Kim, S., Suk, J., Longpre, S., Lin, B. Y., Shin, J., Welleck, S., Neubig, G., Lee, M., Lee, K., Seo, M. &lt;em>Prometheus 2: An Open Source Language Model Specialized in Evaluating Other Language Models&lt;/em> (EMNLP 2024).&lt;/li>
&lt;li>Verga, P., Hofstatter, S., Althammer, S., Su, Y., Piktus, A., Arkhangorodsky, A., Xu, M., White, N., Lewis, P. &lt;em>Replacing Judges with Juries: Evaluating LLM Generations with a Panel of Diverse Models&lt;/em> (Cohere, 2024).&lt;/li>
&lt;li>Panickssery, A., Bowman, S., Feng, S. &lt;em>LLM Evaluators Recognize and Favor Their Own Generations&lt;/em> (Anthropic, NeurIPS 2024).&lt;/li>
&lt;li>Wang, P., Li, L., Chen, L., Cai, Z., Zhu, D., Lin, B., Cao, Y., Liu, Q., Liu, T., Sui, Z. &lt;em>Large Language Models are Not Fair Evaluators&lt;/em> (ACL 2024).&lt;/li>
&lt;li>Dubois, Y., Galambosi, B., Liang, P., Hashimoto, T. &lt;em>Length-Controlled AlpacaEval: A Simple Way to Debias Automatic Evaluators&lt;/em> (Stanford, 2024).&lt;/li>
&lt;li>Cohen, J. &lt;em>A Coefficient of Agreement for Nominal Scales&lt;/em> (Educational and Psychological Measurement, 1960).&lt;/li>
&lt;li>Landis, J. R., Koch, G. G. &lt;em>The Measurement of Observer Agreement for Categorical Data&lt;/em> (Biometrics, 1977).&lt;/li>
&lt;/ul></description></item><item><title>Reranker and hybrid retrieval: the committee that decides the 5 chunks the LLM will actually read</title><link>https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/</link><pubDate>Mon, 25 May 2026 14:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/</guid><description>&lt;blockquote>
&lt;p>This post takes apart the retrieval layer inside the RAG piece of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a>. It sits directly on top of the &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">corpus curation post&lt;/a>: if the librarian decided &lt;strong>what enters the index&lt;/strong>, the committee in this post decides &lt;strong>what leaves the index for the LLM&amp;rsquo;s face&lt;/strong>. It is the stage that moves the real quality of a RAG in production the most, and the one most teams solve with &amp;ldquo;dense top-k and off we go&amp;rdquo;.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The most widespread antipattern in real RAG is: curated corpus, dense embeddings with &lt;code>bge-m3&lt;/code>, the user&amp;rsquo;s query straight through, &lt;code>top_k=5&lt;/code> of cosine similarity and off to the LLM. The system delivers answers that look good in the demo, mediocre with real traffic and dreadful with three-word queries, technical jargon, internal abbreviations or anything outside the embedder&amp;rsquo;s fine-tuning domain. The root cause is almost never the model: it is that &lt;strong>a single pass of dense retrieval over the raw query is structurally insufficient&lt;/strong> for the cases a real user produces. In 2026 the field has consolidated the &lt;strong>three-layer retrieval&lt;/strong> pattern: a broad layer that mixes dense + sparse (BM25 / SPLADE) via Reciprocal Rank Fusion to secure recall, a fine layer with a &lt;strong>cross-encoder reranker&lt;/strong> (BAAI/bge-reranker-v2-m3, Cohere Rerank 3, mxbai-rerank-large) that reorders the 30-50 candidates by true relevance, and optionally a late interaction layer with ColBERT-v2 or an LLM reranker for critical cases. On top of it all, a &lt;strong>query rewriting&lt;/strong> pattern that rewrites malformed queries and HyDE, which generates a hypothetical document so you embed the &lt;em>expected answer&lt;/em> instead of the &lt;em>question&lt;/em>. This post goes through them one by one: why each layer exists, what latency budget it consumes, the minimum maths of RRF and of the recall/precision trade-off, the dominant OSS stack in 2026, the on-premise hardware for serving it under data sovereignty, and the seven operational traps that kill the stage.&lt;/p>
&lt;h2 id="the-analogy-the-committee-for-a-professorial-chair">The analogy: the committee for a professorial chair&lt;/h2>
&lt;p>A university department has a chair to fill and receives 3,000 applications. Nobody is going to read 3,000 CVs in depth. The real process is organised in rounds:&lt;/p>
&lt;p>&lt;strong>Round 1, mass pre-screening.&lt;/strong> A system of automatic filters reads each CV in seconds. One route looks at the keywords of the post (publications in specific journals, languages, certifications); another route looks at the overall profile (research area, h-index, career). The two routes run in parallel. Each proposes its shortlist of 30-50 candidates. The two rankings are combined and what remains is a list of some 50-100 candidates for the next round to act on. The metric that matters here is &lt;strong>recall&lt;/strong>: not losing the good candidate through a filter error.&lt;/p>
&lt;p>&lt;strong>Round 2, careful reading.&lt;/strong> A member of the panel reads each CV on the shortlist thoroughly and scores it against the real profile of the post. It is slow, 10-20 minutes per CV, but it discriminates far better than the automatic filter. It reorders the 100 candidates into a fine ranking and keeps the 5 finalists. The metric that matters here is &lt;strong>precision&lt;/strong>: that the top-5 is genuinely the real top-5.&lt;/p>
&lt;p>&lt;strong>Round 3 (optional), personal interview.&lt;/strong> Long interviews are held for the 5 finalists. Face to face, questions adapted to each candidate&amp;rsquo;s profile, a check against the reality of the post. It is extremely expensive and very slow, but for critical chairs it justifies the cost.&lt;/p>
&lt;p>The retrieval of a modern RAG works exactly like this. Change the vocabulary and the structure holds:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="RAG retrieval as a committee for a professorial chair">
&lt;style>
.rbox{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.rhead{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.rspar{fill:#a8d5a8;stroke:#444;stroke-width:1.4;rx:8}
.rdens{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.rfuse{fill:#d8a8ff;stroke:#444;stroke-width:1.4;rx:8}
.rrer{fill:#ffb86b;stroke:#444;stroke-width:1.4;rx:8}
.rllm{fill:#ffe18a;stroke:#444;stroke-width:1.4;rx:8}
.rfin{fill:#f4f4f4;stroke:#444;stroke-width:1.4;rx:8}
.rblt{font:600 13px sans-serif;fill:#222}
.rsub{font:400 11px sans-serif;fill:#444}
.rarr{stroke:#666;stroke-width:1.5;fill:none;marker-end:url(#mr1)}
.rlbl{font:600 11px sans-serif;fill:#555}
&lt;/style>
&lt;defs>&lt;marker id="mr1" 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="160" height="60" class="rhead"/>
&lt;text x="100" y="44" text-anchor="middle" class="rblt">User query&lt;/text>
&lt;text x="100" y="62" text-anchor="middle" class="rsub">"how do I cancel subscription"&lt;/text>
&lt;text x="100" y="76" text-anchor="middle" class="rsub">+ rewriter / HyDE&lt;/text>
&lt;rect x="220" y="20" width="160" height="60" class="rspar"/>
&lt;text x="300" y="42" text-anchor="middle" class="rblt">Layer 1a · Sparse&lt;/text>
&lt;text x="300" y="60" text-anchor="middle" class="rsub">BM25 / SPLADE&lt;/text>
&lt;text x="300" y="76" text-anchor="middle" class="rsub">top-50 lexical&lt;/text>
&lt;rect x="220" y="100" width="160" height="60" class="rdens"/>
&lt;text x="300" y="122" text-anchor="middle" class="rblt">Layer 1b · Dense&lt;/text>
&lt;text x="300" y="140" text-anchor="middle" class="rsub">bi-encoder (bge-m3)&lt;/text>
&lt;text x="300" y="156" text-anchor="middle" class="rsub">top-50 semantic&lt;/text>
&lt;rect x="420" y="60" width="150" height="60" class="rfuse"/>
&lt;text x="495" y="82" text-anchor="middle" class="rblt">Layer 2 · Fusion&lt;/text>
&lt;text x="495" y="100" text-anchor="middle" class="rsub">Reciprocal Rank Fusion&lt;/text>
&lt;text x="495" y="116" text-anchor="middle" class="rsub">→ top-30 combined&lt;/text>
&lt;rect x="600" y="60" width="160" height="60" class="rrer"/>
&lt;text x="680" y="82" text-anchor="middle" class="rblt">Layer 3 · Reranker&lt;/text>
&lt;text x="680" y="100" text-anchor="middle" class="rsub">cross-encoder bge-rerank-v2&lt;/text>
&lt;text x="680" y="116" text-anchor="middle" class="rsub">→ top-5 reordered&lt;/text>
&lt;rect x="280" y="200" width="220" height="60" class="rllm"/>
&lt;text x="390" y="222" text-anchor="middle" class="rblt">Layer 4 (optional) · ColBERT / LLM reranker&lt;/text>
&lt;text x="390" y="240" text-anchor="middle" class="rsub">late interaction or LLM judge&lt;/text>
&lt;text x="390" y="256" text-anchor="middle" class="rsub">for critical cases&lt;/text>
&lt;rect x="280" y="290" width="220" height="60" class="rfin"/>
&lt;text x="390" y="312" text-anchor="middle" class="rblt">LLM context&lt;/text>
&lt;text x="390" y="330" text-anchor="middle" class="rsub">5 chunks ready to answer&lt;/text>
&lt;text x="390" y="346" text-anchor="middle" class="rsub">~2,000 tokens&lt;/text>
&lt;path class="rarr" d="M180,40 L220,40"/>
&lt;path class="rarr" d="M180,60 L220,120"/>
&lt;path class="rarr" d="M380,40 L420,80"/>
&lt;path class="rarr" d="M380,120 L420,100"/>
&lt;path class="rarr" d="M570,90 L600,90"/>
&lt;path class="rarr" d="M680,120 L390,200"/>
&lt;path class="rarr" d="M390,260 L390,290"/>
&lt;text x="200" y="200" class="rlbl">broad recall&lt;/text>
&lt;text x="200" y="216" class="rlbl">~50-100ms total&lt;/text>
&lt;text x="450" y="40" class="rlbl">k=60 typical&lt;/text>
&lt;text x="630" y="50" class="rlbl">fine precision&lt;/text>
&lt;text x="630" y="66" class="rlbl">~80-300ms&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The equivalence is exact. The &lt;strong>broad layer&lt;/strong> guarantees that the right chunk is on the shortlist (recall). The &lt;strong>fine layer&lt;/strong> orders well inside the shortlist (precision). Each layer is justified by an operational asymmetry: the broad one is cheap and parallelises, the fine one is expensive and sequential. Solving everything with the expensive one, a single cross-encoder over the whole corpus, is operationally unsustainable. Solving everything with the cheap one, a single dense top-k, is functionally insufficient.&lt;/p>
&lt;h2 id="the-problem-why-dense-on-its-own-is-not-enough">The problem: why dense on its own is not enough&lt;/h2>
&lt;p>The argument against dense-only is not theoretical. It is that the queries a real user produces have patterns the dense bi-encoder &lt;em>does not capture well by construction&lt;/em>:&lt;/p>
&lt;p>&lt;strong>Short queries.&lt;/strong> &amp;ldquo;IRPF dates 2026&amp;rdquo; goes through the embedder and produces a vector close to any chunk that mentions dates and IRPF, including chunks from 2023, chunks of generic calendars, chunks of corporate IRPF when you were asking about personal income tax. &lt;strong>BM25 disambiguates better&lt;/strong> because the weight of the infrequent terms (&amp;ldquo;2026&amp;rdquo;, &amp;ldquo;IRPF&amp;rdquo;) dominates the score.&lt;/p>
&lt;p>&lt;strong>Infrequent or internal technical terms.&lt;/strong> Product codes (&lt;code>FBR-X42-PRO&lt;/code>), organisation abbreviations (&lt;code>PYM-23-bis&lt;/code>), internal variable names. The embedder, trained on a public corpus, breaks them down into sub-tokens and pushes them into the &amp;ldquo;noisy&amp;rdquo; zone of the latent space. Two completely different codes can end up 0.02 cosine distance apart. &lt;strong>BM25 treats them as exact tokens&lt;/strong> and scores them correctly.&lt;/p>
&lt;p>&lt;strong>Polysemy.&lt;/strong> &amp;ldquo;Java&amp;rdquo; as a language vs Java as an island vs Java as coffee. The dense embedding averages the meanings; the ranking becomes mediocre. Here dense can beat BM25 &lt;em>if&lt;/em> the query carries enough context, but that &amp;ldquo;if&amp;rdquo; is rarely met with real user queries.&lt;/p>
&lt;p>&lt;strong>Synonyms and rephrasings.&lt;/strong> &amp;ldquo;How do I cancel my subscription&amp;rdquo; vs a chunk titled &amp;ldquo;Service termination procedure&amp;rdquo;. Here dense beats BM25: the semantic space brings them together even though they share no tokens. It is exactly the case where dense shines and BM25 fails.&lt;/p>
&lt;p>The operational conclusion: &lt;strong>no single retriever covers the four cases&lt;/strong>. The combination covers them all. And for the cases where both retrievers fail (very ambiguous queries, contradictory context between chunks), the &lt;strong>third layer&lt;/strong> comes in: the reranker that reads the query and each chunk together with a cross-encoder, and produces a &lt;em>real&lt;/em> relevance score far more discriminating than cosine similarity.&lt;/p>
&lt;h2 id="layer-1--hybrid-retrieval-dense--sparse">Layer 1 — Hybrid retrieval: dense + sparse&lt;/h2>
&lt;h3 id="why-bm25-is-still-alive-in-2026">Why BM25 is still alive in 2026&lt;/h3>
&lt;p>BM25 is from 1994. It is not deep learning. It needs no GPU. And it is still competitive with dense retrievers of hundreds of millions of parameters on many retrieval benchmarks (&lt;a href="https://github.com/beir-cellar/beir">BEIR&lt;/a>, 2021-2024). Its formula is simple:&lt;/p>
$$
\mathrm{BM25}(q, d) = \sum_{t \in q} \mathrm{IDF}(t) \cdot \frac{\mathrm{tf}(t,d) \cdot (k_1 + 1)}{\mathrm{tf}(t,d) + k_1 \cdot (1 - b + b \cdot \frac{|d|}{\overline{|d|}})}
$$
&lt;p>Where $\mathrm{tf}(t,d)$ is the frequency of term $t$ in document $d$, $|d|$ the document length, $\overline{|d|}$ the mean length of the corpus, and IDF the inverse document frequency. $k_1$ and $b$ are hyperparameters (typically $k_1 = 1.2$, $b = 0.75$). In practice you use &lt;code>pyserini&lt;/code>, &lt;code>Tantivy&lt;/code>, &lt;code>Elasticsearch&lt;/code> or &lt;code>OpenSearch&lt;/code> and never write the formula.&lt;/p>
&lt;p>SPLADE (&lt;a href="https://arxiv.org/abs/2107.05720">Formal et al., 2021&lt;/a>) is the neural generation of BM25: it learns sparse weights over the BERT vocabulary, expanding each token to its related terms. It keeps BM25&amp;rsquo;s interpretability (you can see which query terms match which document terms) and beats plain BM25 on BEIR. The price is that you need GPU inference over the query (and over each document at indexing time, once).&lt;/p>
&lt;p>The rule of thumb in 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Corpus &amp;lt; 10M chunks, queries with a broad vocabulary&lt;/strong>: plain BM25 via Elasticsearch / Tantivy. Trivial indexing, 1-5ms queries.&lt;/li>
&lt;li>&lt;strong>Corpus 10M-100M, specific domain&lt;/strong>: SPLADE-v3 indexed in Elasticsearch or Vespa. Slow indexing (you need a GPU once), 5-20ms queries.&lt;/li>
&lt;li>&lt;strong>Corpus &amp;gt; 100M, latency critical&lt;/strong>: BM25 with external query expansion (synonyms, query2doc) is the operational option, with SPLADE reserved for the critical segments.&lt;/li>
&lt;/ul>
&lt;h3 id="dense-bi-encoder-the-second-lane">Dense bi-encoder: the second lane&lt;/h3>
&lt;p>The bi-encoder produces one vector per chunk and another per query, and relevance is the cosine similarity (or dot product) between the two. The dominant multilingual model in 2026 is &lt;code>BAAI/bge-m3&lt;/code>: 568M params, 1024 dimensions, support for up to 8,192 context tokens per chunk, multivector (dense + sparse + ColBERT-style multi-vec in the same embedder), trained on 100+ languages. Alternatives:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Embedder&lt;/th>
&lt;th style="text-align:right">Params&lt;/th>
&lt;th style="text-align:right">Dim&lt;/th>
&lt;th style="text-align:center">Multilingual&lt;/th>
&lt;th>Comment&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>BAAI/bge-m3&lt;/code>&lt;/td>
&lt;td style="text-align:right">568M&lt;/td>
&lt;td style="text-align:right">1024&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td>the default all-rounder in 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>intfloat/multilingual-e5-large-instruct&lt;/code>&lt;/td>
&lt;td style="text-align:right">560M&lt;/td>
&lt;td style="text-align:right">1024&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td>direct competitor, instruct-style queries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>nomic-ai/nomic-embed-text-v1.5&lt;/code>&lt;/td>
&lt;td style="text-align:right">137M&lt;/td>
&lt;td style="text-align:right">768&lt;/td>
&lt;td style="text-align:center">English&lt;/td>
&lt;td>fast, variable Matryoshka dimension&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>jinaai/jina-embeddings-v3&lt;/code>&lt;/td>
&lt;td style="text-align:right">570M&lt;/td>
&lt;td style="text-align:right">1024&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td>with task-specific LoRAs per domain&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mixedbread-ai/mxbai-embed-large-v1&lt;/code>&lt;/td>
&lt;td style="text-align:right">335M&lt;/td>
&lt;td style="text-align:right">1024&lt;/td>
&lt;td style="text-align:center">English&lt;/td>
&lt;td>top English on MTEB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Snowflake/arctic-embed-l-v2.0&lt;/code>&lt;/td>
&lt;td style="text-align:right">568M&lt;/td>
&lt;td style="text-align:right">1024&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td>enterprise-oriented, Apache licence&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The practical selection criterion: &lt;strong>multilingual no matter what if your corpus or queries need it&lt;/strong> (&lt;code>bge-m3&lt;/code>, &lt;code>multilingual-e5&lt;/code>, &lt;code>jina-v3&lt;/code>, &lt;code>arctic-l-v2&lt;/code>), Matryoshka if you want to save vector store memory by reducing dimensions without re-embedding (&lt;code>nomic&lt;/code>), and the MTEB leaderboard ranking only as a &lt;strong>tie-breaker&lt;/strong>, never as the sole criterion, since MTEB benchmarks are contaminable and a 1-2 point difference rarely translates into a real improvement in your domain.&lt;/p>
&lt;h3 id="the-fusion-reciprocal-rank-fusion-rrf">The fusion: Reciprocal Rank Fusion (RRF)&lt;/h3>
&lt;p>You have two rankings, one from BM25 and one from dense. How do you combine them? The naive option, adding the scores, fails because the scores are not comparable (BM25 produces numbers of 0-30, cosine 0-1, L2 distances 0-2…). The option that has consolidated is Reciprocal Rank Fusion (&lt;a href="https://dl.acm.org/doi/10.1145/1571941.1572114">Cormack, Clarke, Buettcher 2009&lt;/a>), which ignores absolute scores and uses only the &lt;em>rankings&lt;/em>:&lt;/p>
$$
\mathrm{RRF}(d) = \sum_{r \in R} \frac{1}{k + \mathrm{rank}_r(d)}
$$
&lt;p>Where $R$ is the set of retrievers and $\mathrm{rank}_r(d)$ is the position of document $d$ in the ranking of retriever $r$. The constant $k$ is typically $60$ and smooths the weight of the first positions.&lt;/p>
&lt;p>A numerical example. You have BM25 with top-5: &lt;code>[d_a, d_b, d_c, d_d, d_e]&lt;/code> and dense with top-5: &lt;code>[d_b, d_f, d_a, d_g, d_h]&lt;/code>. You compute the RRF of each candidate:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Doc&lt;/th>
&lt;th style="text-align:right">BM25 rank&lt;/th>
&lt;th style="text-align:right">dense rank&lt;/th>
&lt;th style="text-align:right">$\frac{1}{60+r_{\text{BM25}}}$&lt;/th>
&lt;th style="text-align:right">$\frac{1}{60+r_{\text{dense}}}$&lt;/th>
&lt;th style="text-align:right">total RRF&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>d_b&lt;/code>&lt;/td>
&lt;td style="text-align:right">2&lt;/td>
&lt;td style="text-align:right">1&lt;/td>
&lt;td style="text-align:right">0.01613&lt;/td>
&lt;td style="text-align:right">0.01639&lt;/td>
&lt;td style="text-align:right">0.03252&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>d_a&lt;/code>&lt;/td>
&lt;td style="text-align:right">1&lt;/td>
&lt;td style="text-align:right">3&lt;/td>
&lt;td style="text-align:right">0.01639&lt;/td>
&lt;td style="text-align:right">0.01587&lt;/td>
&lt;td style="text-align:right">0.03226&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>d_c&lt;/code>&lt;/td>
&lt;td style="text-align:right">3&lt;/td>
&lt;td style="text-align:right">—&lt;/td>
&lt;td style="text-align:right">0.01587&lt;/td>
&lt;td style="text-align:right">0&lt;/td>
&lt;td style="text-align:right">0.01587&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>d_f&lt;/code>&lt;/td>
&lt;td style="text-align:right">—&lt;/td>
&lt;td style="text-align:right">2&lt;/td>
&lt;td style="text-align:right">0&lt;/td>
&lt;td style="text-align:right">0.01613&lt;/td>
&lt;td style="text-align:right">0.01613&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>d_d&lt;/code>&lt;/td>
&lt;td style="text-align:right">4&lt;/td>
&lt;td style="text-align:right">—&lt;/td>
&lt;td style="text-align:right">0.01562&lt;/td>
&lt;td style="text-align:right">0&lt;/td>
&lt;td style="text-align:right">0.01562&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;code>d_b&lt;/code> wins because it appears well ranked in both. &lt;code>d_a&lt;/code> follows it. Those that appear in only one list stay below. With nothing tuned, RRF tends to push the &lt;em>consensus&lt;/em> candidates to the top, which is exactly what you want as a coarse filter before the reranker.&lt;/p>
&lt;p>Variants: weighted RRF assigns a multiplier per retriever ($w_r \cdot \frac{1}{k + \mathrm{rank}_r(d)}$) when you have reasons to trust one more; learning-to-rank trains a model (LambdaMART or a LightGBM ranker) over features from the two retrievers, a more powerful option but one that demands relevance labels most teams do not have.&lt;/p>
&lt;p>In Qdrant, Vespa, Weaviate and Elastic 8.11+ the RRF fusion is built in as a native mode: you ask for hybrid search with a lexical query and a vector and they return the combined top-k without you having to compute the fusion.&lt;/p>
&lt;h2 id="layer-2--the-reranker-bi-encoder-vs-cross-encoder">Layer 2 — The reranker: bi-encoder vs cross-encoder&lt;/h2>
&lt;p>The bi-encoder looks at query and document &lt;em>separately&lt;/em> and compares the resulting vectors. It is extremely fast (a dot product) and lets you index the document embeddings once and for all. The price is that the model never sees query and document together, and the compressed representation loses nuance.&lt;/p>
&lt;p>The cross-encoder looks at query and document &lt;em>concatenated&lt;/em>: &lt;code>[CLS] query [SEP] document [SEP]&lt;/code> goes through the model and the output is a relevance score. It discriminates far better, since shades of ordering and of local relationship between terms are captured, but the cost explodes: each (query, doc) pair requires a full inference of the model. You cannot precompute anything. If you want to rerank 50 candidates, that is 50 inferences.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Aspect&lt;/th>
&lt;th>Bi-encoder&lt;/th>
&lt;th>Cross-encoder&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Inference per chunk at indexing&lt;/td>
&lt;td>yes, once&lt;/td>
&lt;td>not precomputable&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Inference at query time&lt;/td>
&lt;td>1 (query) + dot product per chunk&lt;/td>
&lt;td>1 per (query, chunk) pair&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Typical latency for top-50&lt;/td>
&lt;td>5-20 ms (CPU possible)&lt;/td>
&lt;td>80-300 ms (GPU recommended)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Discrimination&lt;/td>
&lt;td>medium&lt;/td>
&lt;td>high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Typical use&lt;/td>
&lt;td>recall (layer 1)&lt;/td>
&lt;td>precision (layer 2)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>This asymmetry is why the canonical pattern is a &lt;strong>two-step funnel&lt;/strong>: the bi-encoder does cheap broad recall and the cross-encoder does expensive fine precision, over 30-50 candidates instead of millions.&lt;/p>
&lt;h3 id="2026-oss-cross-encoder-reranker-stack">2026 OSS cross-encoder reranker stack&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Reranker&lt;/th>
&lt;th style="text-align:right">Params&lt;/th>
&lt;th style="text-align:center">Multilingual&lt;/th>
&lt;th style="text-align:right">top-50 latency (A100)&lt;/th>
&lt;th>Comment&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>BAAI/bge-reranker-v2-m3&lt;/code>&lt;/td>
&lt;td style="text-align:right">568M&lt;/td>
&lt;td style="text-align:center">yes (100+ languages)&lt;/td>
&lt;td style="text-align:right">~90 ms&lt;/td>
&lt;td>the de facto default in 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>BAAI/bge-reranker-v2-gemma&lt;/code>&lt;/td>
&lt;td style="text-align:right">2B&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td style="text-align:right">~250 ms&lt;/td>
&lt;td>more quality, more cost&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mixedbread-ai/mxbai-rerank-large-v2&lt;/code>&lt;/td>
&lt;td style="text-align:right">1.5B&lt;/td>
&lt;td style="text-align:center">English&lt;/td>
&lt;td style="text-align:right">~180 ms&lt;/td>
&lt;td>top English on BEIR rerank&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>jinaai/jina-reranker-v2-base-multilingual&lt;/code>&lt;/td>
&lt;td style="text-align:right">278M&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td style="text-align:right">~50 ms&lt;/td>
&lt;td>fast and enough for many cases&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Alibaba-NLP/gte-multilingual-reranker-base&lt;/code>&lt;/td>
&lt;td style="text-align:right">306M&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td style="text-align:right">~55 ms&lt;/td>
&lt;td>direct competitor to jina&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Cohere Rerank 3&lt;/code>&lt;/td>
&lt;td style="text-align:right">(closed)&lt;/td>
&lt;td style="text-align:center">yes (100+ languages)&lt;/td>
&lt;td style="text-align:right">~120 ms (API)&lt;/td>
&lt;td>commercial, not on-prem&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Voyage rerank-2&lt;/code>&lt;/td>
&lt;td style="text-align:right">(closed)&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td style="text-align:right">~100 ms (API)&lt;/td>
&lt;td>commercial, not on-prem&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For deployments under data sovereignty the obvious choice is &lt;strong>&lt;code>bge-reranker-v2-m3&lt;/code>&lt;/strong> as a starting point, with &lt;code>jina-reranker-v2-base&lt;/code> as the lightweight alternative. If the domain is exclusively English and the hardware budget allows it, &lt;code>mxbai-rerank-large-v2&lt;/code> takes a point or two more on BEIR. Cohere and Voyage are the option when you can reach out to an external API, ruled out in ENS / NIS2 scenarios with sensitive data.&lt;/p>
&lt;h3 id="late-interaction-colbert-v2-as-a-compromise">Late interaction: ColBERT-v2 as a compromise&lt;/h3>
&lt;p>There is a third option gaining traction: late interaction, embodied in ColBERT-v2 (&lt;a href="https://arxiv.org/abs/2112.01488">Santhanam et al., 2022&lt;/a>). The idea is that the document is embedded not as a single vector but as a &lt;em>matrix&lt;/em> of embeddings, one per token. And at query time the query is also embedded as a matrix, and the similarity is the sum of the maxima per column (&lt;code>MaxSim&lt;/code>):&lt;/p>
$$
S(q, d) = \sum_{i \in q} \max_{j \in d} \langle E_q[i], E_d[j] \rangle
$$
&lt;p>The result is retrieval with quality close to a cross-encoder but at a much lower cost (there is no need to run the whole model over each pair). The price is space: each chunk is ~150-200 vectors instead of 1, and that multiplies the index size by ~100. For small to medium corpora (&amp;lt;10M chunks) it is manageable; for huge corpora &lt;code>PLAID&lt;/code> compression and &lt;code>ColBERT-v2 quantization&lt;/code> make it viable up to 100M.&lt;/p>
&lt;p>In 2026 the reference implementations are &lt;code>RAGatouille&lt;/code> (Python wrapper), &lt;code>JaColBERT&lt;/code> (Japanese), &lt;code>Vespa.ai&lt;/code> (a search engine with native ColBERT support as a first-class citizen), and the native integration in &lt;code>bge-m3&lt;/code> that produces the ColBERT-style multivectors as part of the same dense model.&lt;/p>
&lt;p>My operational recommendation: &lt;strong>start with a cross-encoder reranker&lt;/strong> over the top-30 of the hybrid retrieval. Only if the precision numbers fall short, try late interaction as an alternative to the cross-encoder (not as an additional layer). Late interaction as a &lt;em>third&lt;/em> layer rarely pays back the operational cost for most cases.&lt;/p>
&lt;h2 id="query-rewriting-and-hyde-correcting-the-query-before-retrieval">Query rewriting and HyDE: correcting the query before retrieval&lt;/h2>
&lt;p>The user&amp;rsquo;s query is the worst-controlled input in the whole system. The most frequent problems:&lt;/p>
&lt;p>&lt;strong>Queries that are too short&lt;/strong>: &amp;ldquo;GDPR fines&amp;rdquo; → retrieves any chunk about GDPR or about fines.&lt;/p>
&lt;p>&lt;strong>Queries with typos or jargon&lt;/strong>: &amp;ldquo;i wnat to know how to cancel the premium subscripton&amp;rdquo; → the embedder is noisy because of the typos.&lt;/p>
&lt;p>&lt;strong>Multi-intent queries&lt;/strong>: &amp;ldquo;compare the premium and enterprise plans and tell me which has the better SLA&amp;rdquo; → dense will try to embed the three questions into one vector.&lt;/p>
&lt;p>&lt;strong>Queries with references resolved in context&lt;/strong>: &amp;ldquo;and for next month?&amp;rdquo; → without the previous context, dense does not know what it is about.&lt;/p>
&lt;p>&lt;strong>Canonical 2026 patterns to mitigate&lt;/strong>:&lt;/p>
&lt;p>&lt;strong>Query rewriting with a light LLM.&lt;/strong> Before retrieval, a small model (Qwen2.5-7B, Llama-3.2-3B, Phi-4-mini) rewrites the user&amp;rsquo;s query into a &amp;ldquo;canonical&amp;rdquo; version: it expands abbreviations, fixes typos, breaks multi-intent into sub-queries, resolves references using the conversation history. Cost: one cheap inference (~50-100 ms). Benefit: the four pathologies above are noticeably reduced.&lt;/p>
&lt;p>&lt;strong>Multi-query&lt;/strong>: the LLM generates 3-5 rephrasings of the query and you retrieve with each one; then you fuse with RRF. It increases recall at the cost of time and LLM tokens.&lt;/p>
&lt;p>&lt;strong>Step-back prompting&lt;/strong> (&lt;a href="https://arxiv.org/abs/2310.06117">Zheng et al., 2023&lt;/a>): the LLM generates a &lt;em>more general&lt;/em> question than the original query (&amp;ldquo;what is a premium subscription in this system?&amp;rdquo;), you retrieve with both and combine. It helps with very specific queries that need context.&lt;/p>
&lt;p>&lt;strong>HyDE — Hypothetical Document Embeddings&lt;/strong> (&lt;a href="https://arxiv.org/abs/2212.10496">Gao et al., 2022&lt;/a>): instead of embedding the question, you ask a light LLM to generate a &lt;em>hypothetical answer&lt;/em> and you embed that. The reasoning is that the semantic space of answers is closer to that of the real chunks than the space of questions. It works especially well in domains with very specific vocabulary. Cost: one inference of the light LLM (~100-200 ms). Benefit: in many cases +5-15 points of Recall@10.&lt;/p>
&lt;p>The operational trade-off is the added latency. Each technique adds 50-300 ms to the pipeline. In synchronous interactions with a 2s SLO there is still headroom; in agentic interactions with tool-use loops, every ms counts. The practical choice in 2026: &lt;strong>rewriting with a light LLM as the default&lt;/strong>, &lt;strong>HyDE for specific cases where it has been measured to help&lt;/strong>, &lt;strong>multi-query and step-back only when the case justifies it with numbers&lt;/strong>.&lt;/p>
&lt;h2 id="minimum-maths-latency-budget-and-metrics">Minimum maths: latency budget and metrics&lt;/h2>
&lt;h3 id="latency-budget-of-a-canonical-pipeline">Latency budget of a canonical pipeline&lt;/h3>
&lt;p>A typical hybrid + rerank pipeline in 2026 production over a corpus of 5M chunks with bge-m3 + bge-reranker-v2-m3 on an H100 with TEI:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th style="text-align:right">p50 latency&lt;/th>
&lt;th style="text-align:right">p95 latency&lt;/th>
&lt;th>Comment&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Query rewrite (LLM 3B)&lt;/td>
&lt;td style="text-align:right">60 ms&lt;/td>
&lt;td style="text-align:right">150 ms&lt;/td>
&lt;td>only if enabled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HyDE (LLM 3B)&lt;/td>
&lt;td style="text-align:right">90 ms&lt;/td>
&lt;td style="text-align:right">220 ms&lt;/td>
&lt;td>only if enabled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>BM25 / SPLADE top-50&lt;/td>
&lt;td style="text-align:right">8 ms&lt;/td>
&lt;td style="text-align:right">20 ms&lt;/td>
&lt;td>Elasticsearch or local Tantivy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dense embedding of the query&lt;/td>
&lt;td style="text-align:right">12 ms&lt;/td>
&lt;td style="text-align:right">35 ms&lt;/td>
&lt;td>bge-m3 with batch=1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dense top-50 (filterable HNSW)&lt;/td>
&lt;td style="text-align:right">6 ms&lt;/td>
&lt;td style="text-align:right">18 ms&lt;/td>
&lt;td>Qdrant with scalar quantization&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RRF fusion → top-30&lt;/td>
&lt;td style="text-align:right">&amp;lt;1 ms&lt;/td>
&lt;td style="text-align:right">&amp;lt;1 ms&lt;/td>
&lt;td>arithmetic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cross-encoder rerank top-30&lt;/td>
&lt;td style="text-align:right">60 ms&lt;/td>
&lt;td style="text-align:right">180 ms&lt;/td>
&lt;td>bge-reranker-v2-m3 batched&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Top-5 selection + format&lt;/td>
&lt;td style="text-align:right">&amp;lt;1 ms&lt;/td>
&lt;td style="text-align:right">&amp;lt;1 ms&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total without rewrite/HyDE&lt;/strong>&lt;/td>
&lt;td style="text-align:right">~90 ms&lt;/td>
&lt;td style="text-align:right">~270 ms&lt;/td>
&lt;td>realistic range on an H100&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total with rewrite + HyDE&lt;/strong>&lt;/td>
&lt;td style="text-align:right">~240 ms&lt;/td>
&lt;td style="text-align:right">~640 ms&lt;/td>
&lt;td>+ ~150-370 ms&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>On an RTX 4090 (~3× slower than an H100 on a large cross-encoder, similar on everything else) the numbers shift: ~180 ms p50 without rewrite, ~400 ms p50 with rewrite + HyDE. It is perfectly serviceable for a synchronous assistant with a 2-3s SLO, but tight for one with a 500 ms SLO.&lt;/p>
&lt;h3 id="metrics-ndcg-mrr-recallk">Metrics: nDCG, MRR, Recall@k&lt;/h3>
&lt;p>There are three classic metrics that any retrieval eval reports:&lt;/p>
&lt;p>&lt;strong>Recall@k&lt;/strong>: is the relevant chunk among the top-k? A binary metric, it ignores ordering. The one that matters for the &lt;strong>broad layer&lt;/strong>.&lt;/p>
$$
\mathrm{Recall@k} = \frac{|\{\text{relevant}\} \cap \{\text{top-k}\}|}{|\{\text{relevant}\}|}
$$
&lt;p>&lt;strong>Mean Reciprocal Rank (MRR@k)&lt;/strong>: in which position does the first relevant chunk appear? It penalises placing it far down.&lt;/p>
$$
\mathrm{MRR@k} = \frac{1}{|Q|} \sum_{q \in Q} \frac{1}{\mathrm{rank}_q}
$$
&lt;p>where $\mathrm{rank}_q$ is the position of the first relevant item for query $q$ (or $\infty$ if it does not appear in the top-k).&lt;/p>
&lt;p>&lt;strong>nDCG@k&lt;/strong>: it takes into account multiple relevant chunks with degrees of relevance. The most used metric for evaluating rerankers:&lt;/p>
$$
\mathrm{DCG@k} = \sum_{i=1}^{k} \frac{2^{\mathrm{rel}_i} - 1}{\log_2(i+1)} \quad \mathrm{nDCG@k} = \frac{\mathrm{DCG@k}}{\mathrm{IDCG@k}}
$$
&lt;p>The operational heuristic: &lt;strong>Recall@50 measures the quality of your broad layer&lt;/strong> (we want &amp;gt; 95%; otherwise layer 2 will never be able to recover what was lost), &lt;strong>nDCG@5 measures the quality of your reranker&lt;/strong> (we want &amp;gt; 0.75 on the golden set to consider the system &amp;ldquo;good&amp;rdquo;) and &lt;strong>MRR@5 measures the quality of your top-1&lt;/strong> (it matters especially in systems with &lt;code>top_k=1&lt;/code> or where the LLM&amp;rsquo;s answer is based mainly on the first chunk).&lt;/p>
&lt;h3 id="costs-and-throughput">Costs and throughput&lt;/h3>
&lt;p>Assuming a bge-reranker-v2-m3 cross-encoder (568M params) on an H100 SXM with TEI 1.7 and dynamic batching:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Throughput&lt;/strong>: ~280 queries/sec with top-30 each (= ~8,400 chunk-query pairs/sec).&lt;/li>
&lt;li>&lt;strong>VRAM used&lt;/strong>: ~3.5 GB for the model + ~6 GB of KV cache + activations at batch=32.&lt;/li>
&lt;li>&lt;strong>Energy cost&lt;/strong>: an H100 SXM draws ~700 W. At 8,400 pairs/sec, the energy cost of pure reranking is ~0.083 mJ per pair. In Spanish industrial electricity bill terms (~0.12 €/kWh, May 2026): ~0.000003 € per rerank of 30 candidates. The bill is negligible compared with the cost of the downstream LLM.&lt;/li>
&lt;/ul>
&lt;p>On an RTX 4090 with TEI: ~95 queries/sec at top-30 (~2,850 pairs/sec), VRAM ~3.5 GB + ~5 GB. Serviceable for an internal assistant with modest traffic (~5 QPS sustained, peaks at 20-30).&lt;/p>
&lt;h2 id="the-canonical-pattern--which-piece-goes-where">The canonical pattern — which piece goes where&lt;/h2>
&lt;p>The 2026 operational reference stack for an on-premise hybrid + rerank RAG under data sovereignty:&lt;/p>
&lt;pre tabindex="0">&lt;code> ┌─────────────────┐
User query ────────▶│ Query rewriter │ (optional, LLM 3B)
│ (Qwen2.5-7B-IT) │
└────────┬────────┘
▼
┌─────────────────┐
│ HyDE │ (optional)
│ (same LLM) │
└────────┬────────┘
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ BM25/SPLADE │ │ Dense bi-enc │ │ (metadata │
│ Elasticsearch│ │ bge-m3 + TEI │ │ filters │
│ top-50 │ │ + Qdrant │ │ per tenant) │
│ │ │ top-50 │ │ │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
└─────────┬───────┘ │
▼ │
┌──────────────┐ │
│ RRF k=60 │◀─────────────────┘
│ → top-30 │
└──────┬───────┘
▼
┌──────────────────┐
│ Cross-encoder │
│ bge-reranker-v2 │
│ TEI batch=32 │
│ → final top-5 │
└──────┬───────────┘
▼
┌──────────────────┐
│ LLM context │
│ ~2,000 tokens │
└──────────────────┘
&lt;/code>&lt;/pre>&lt;p>Key services:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Sparse index&lt;/strong>: Elasticsearch 8.16, OpenSearch 2.18 or embedded Tantivy. Native BM25, SPLADE optional via plugin.&lt;/li>
&lt;li>&lt;strong>Vector index&lt;/strong>: Qdrant 1.13 (filterable HNSW, scalar quantization, multivectors). Covered in detail in the &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in ingestion post&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Embedder serving&lt;/strong>: HuggingFace&amp;rsquo;s &lt;a href="https://github.com/huggingface/text-embeddings-inference">TEI (Text Embeddings Inference)&lt;/a> for bge-m3, multilingual-e5 and compatible models. OpenAI-compatible endpoint.&lt;/li>
&lt;li>&lt;strong>Reranker serving&lt;/strong>: TEI too, for the rerankers of the bge-reranker, jina-reranker and mxbai-rerank families. Separate &lt;code>/rerank&lt;/code> endpoint.&lt;/li>
&lt;li>&lt;strong>LLM for rewriter/HyDE&lt;/strong>: vLLM 0.7+ serving Qwen2.5-7B-Instruct or Phi-4-mini-Instruct. If the main LLM is the same, it shares resources; if not, a dedicated pod with GPTQ-INT4 quantization.&lt;/li>
&lt;li>&lt;strong>Orchestration&lt;/strong>: the RAG client (FastAPI, LangChain, LlamaIndex) composes the three lanes. Each parallel call (Sparse + Dense) is made concurrently; rewriter, HyDE and rerank are sequential.&lt;/li>
&lt;/ul>
&lt;p>Minimum Kubernetes manifests are already developed in the &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes post&lt;/a> and the full orchestration piece fits the pattern of the &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">multi-tenant H100 cluster&lt;/a>.&lt;/p>
&lt;h2 id="on-premise-hardware-what-fits-in-an-rtx-4090-vs-a-generic-4h100-sxm-configuration">On-premise hardware: what fits in an RTX 4090 vs a generic 4×H100 SXM configuration&lt;/h2>
&lt;p>The operational reality matters. Two typical profiles:&lt;/p>
&lt;p>&lt;strong>Modest configuration — 1× RTX 4090 (24 GB Ada Lovelace)&lt;/strong>&lt;/p>
&lt;p>It comfortably serves:&lt;/p>
&lt;ul>
&lt;li>bge-m3 (568M) for dense embedding of the query → ~1 GB VRAM&lt;/li>
&lt;li>bge-reranker-v2-m3 (568M) cross-encoder → ~3.5 GB VRAM at batch=32&lt;/li>
&lt;li>A light LLM for rewriter/HyDE (Qwen2.5-7B GPTQ-INT4) → ~5-6 GB VRAM&lt;/li>
&lt;li>The main LLM (Llama-3.1-8B-Instruct FP8) → ~11-12 GB VRAM&lt;/li>
&lt;/ul>
&lt;p>Total VRAM used: ~21 GB. That leaves ~3 GB for the main LLM&amp;rsquo;s KV cache, enough for 4-8 concurrent users with ~4K token contexts. For an internal corporate assistant it is viable. If you need a larger context or more concurrency, you have to split things up: either the main LLM is served outside the 4090 (another GPU, another node), or you fall back on quantising the reranker (not advisable: you lose the nDCG points that justify the cost of adding it).&lt;/p>
&lt;p>&lt;strong>Generic configuration — 4×H100 SXM (320 GB total, NVLink)&lt;/strong>&lt;/p>
&lt;p>It comfortably serves everything above multiplied by 30-50× in throughput and opens the door to:&lt;/p>
&lt;ul>
&lt;li>A large main LLM (Llama 3.3 70B FP8 or Qwen2.5-72B FP8) on 2 GPUs with tensor parallelism&lt;/li>
&lt;li>Large rerankers (bge-reranker-v2-gemma 2B) with quality ~2 points higher&lt;/li>
&lt;li>Late interaction (ColBERT-v2) on one of the nodes for critical cases&lt;/li>
&lt;li>Several light LLMs in parallel for rewriter, HyDE and the evals judge&lt;/li>
&lt;/ul>
&lt;p>The practical rule: &lt;strong>the reranker is one of the most efficient things you can put on a GPU&lt;/strong>. The VRAM used is modest, the throughput is high, and the quality improvement per euro of extra hardware is among the best in the RAG stack.&lt;/p>
&lt;h2 id="the-seven-traps-that-kill-retrieval">The seven traps that kill retrieval&lt;/h2>
&lt;p>&lt;strong>Trap 1 — Top-k to the LLM with no reranker.&lt;/strong> Dense retrieval returns a top-5 and those 5 chunks go straight to the LLM. Without a reranker, the order of the 5 is the cosine similarity order, which is not the real relevance order. The LLM answers based much more on the first chunks than the last ones, and the nuance is lost. Symptom: confident answers with citations that are not the most relevant in the corpus.&lt;/p>
&lt;p>&lt;strong>Trap 2 — Dense-only with no BM25.&lt;/strong> Short queries and queries with internal jargon work badly, the demos look pretty, real traffic complains. Symptom: on QA with real support tickets, the system fails specifically on queries of fewer than 5 words or with product codes.&lt;/p>
&lt;p>&lt;strong>Trap 3 — Reranker badly calibrated on top-k.&lt;/strong> A layer 1 top-k that is too small (top-10) cuts before the reranker can do its job. A top-k that is too large (top-200) blows up latency without improving nDCG. The canonical sweet spot is top-30 to top-50 from the hybrid → top-5 to top-10 from the reranker. Symptom: the reranker&amp;rsquo;s nDCG@5 stuck no matter which reranker model you try.&lt;/p>
&lt;p>&lt;strong>Trap 4 — Embedder and reranker in different languages.&lt;/strong> The corpus is Spanish + Catalan, the embedder is &lt;code>all-MiniLM&lt;/code> (English only), the reranker is &lt;code>bge-reranker-v2-m3&lt;/code> (multilingual). The broad layer retrieves badly because of the embedder, and the reranker has little to rerank. Symptom: Recall@50 &amp;lt; 80%, unrecoverable by changing the reranker.&lt;/p>
&lt;p>&lt;strong>Trap 5 — RRF with &lt;code>k=1&lt;/code>.&lt;/strong> Someone reads the paper, copies the formula and sets &lt;code>k=1&lt;/code> &amp;ldquo;because they do not understand what it does&amp;rdquo;. The result: the first positions of the worst ranking (whichever it is) dominate. The fusion stops averaging and becomes &amp;ldquo;winner-takes-all&amp;rdquo;. Symptom: the final ranking is almost identical to that of just one of the retrievers.&lt;/p>
&lt;p>&lt;strong>Trap 6 — A query rewriter that changes the meaning.&lt;/strong> The LLM rewriter &amp;ldquo;improves&amp;rdquo; &lt;code>&amp;quot;how do I cancel&amp;quot;&lt;/code> into &lt;code>&amp;quot;termination procedure for the premium service for corporate customers&amp;quot;&lt;/code>. It retrieves chunks about corporate customers when the user was a private individual. Over-specification by the rewriter is worse than no rewriter. Symptom: queries from &amp;ldquo;normal&amp;rdquo; users come back worse than the old ones. Mitigation: the rewriter must keep the user&amp;rsquo;s intent and only add context, never assume.&lt;/p>
&lt;p>&lt;strong>Trap 7 — No retrieval telemetry.&lt;/strong> The system serves, the user complains, you do not know whether the fault was the corpus, the retrieval, the reranker or the LLM. Without emitting traces with &lt;code>retrieved_chunks_ids&lt;/code>, &lt;code>retrieved_chunks_scores&lt;/code>, &lt;code>rerank_scores&lt;/code>, &lt;code>query_rewritten_to&lt;/code>, &lt;code>selected_top_k&lt;/code>, debugging is theatre. Symptom: each incident takes days to investigate. The &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">tracing with OTel and MCP&lt;/a> piece covers the canonical pattern.&lt;/p>
&lt;p>All seven are operational. Just as with the corpus, retrieval does not break because the maths is wrong: it breaks because the discipline relaxes. And as in Eval, the metrics can go up while the real experience gets worse, because the golden set accommodates itself to the system instead of the system accommodating itself to the golden set.&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>Embedding model selection and fine-tuning&lt;/strong>: how to choose among the embedders on the MTEB leaderboard without falling into goodharting, when and how to fine-tune an embedder on your own domain with MNR loss or triplet loss, which synthetic dataset is generated with an LLM for training (GPL, InPars, Promptagator), and the gotchas of massive re-embedding when you change model.&lt;/li>
&lt;li>&lt;strong>Semantic cache for RAG&lt;/strong>: how to cache semantically similar queries to serve answers without going through retrieval or the LLM, GPTCache, MeanCache, and the cache&amp;rsquo;s precision/coverage trade-off. It saves 30-70% of the cost on workloads with repeated queries.&lt;/li>
&lt;li>&lt;strong>Multi-vector and ColBERT-v2 at scale&lt;/strong>: how PLAID and CITADEL indexes are designed to serve corpora of 100M+ chunks with late interaction without burning the memory budget.&lt;/li>
&lt;li>&lt;strong>RAG-specific eval — RAGAS deep dive&lt;/strong>: faithfulness, answer relevance, context precision, context recall, noise sensitivity. How the RAG-specific golden set is built (with chunks labelled as relevant/irrelevant) and which metrics correlate with real user satisfaction.&lt;/li>
&lt;li>&lt;strong>Function calling and tool-augmented retrieval&lt;/strong>: when the LLM decides which retriever to invoke (SQL for structured data, vector for unstructured, web search for real time), the ReAct pattern, handling tool errors.&lt;/li>
&lt;li>&lt;strong>Agentic retrieval loops&lt;/strong>: when a single retrieval pass is not enough and the agent iterates (planning, sub-queries, summary-and-refine). The latency / quality trade-off and the anti-patterns (infinite loops, diverging sub-queries).&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/">Serving embeddings and rerankers with TEI in production&lt;/a> — how to deploy the cross-encoder reranker with TEI and its /rerank endpoint.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">Taking RAG to the CPU: separating the data plane from the generation plane&lt;/a> — where each piece runs: light rerank over a top-k fits on CPU; massive rerank demands a GPU.&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 the RAG piece crosses Data + Deploy + Observe. Retrieval lives between the curated corpus and the served LLM.&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> — the previous layer. Without a curated corpus, no reranker rescues the system. Reranking is built on the librarian&amp;rsquo;s work.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings in 2026: the three families, the model zoo and the decision that matters&lt;/a> — the piece that produces the vectors this retrieval consumes. bge-m3 runs dense + sparse + colbert in a single pass; the selection criteria and the real cost per million chunks are there.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/ontologies-knowledge-graphs-llmops-linnaean-nomenclature/">Ontologies and knowledge graphs in LLMOps&lt;/a> — the fourth retrieval channel alongside dense / sparse / multi-vector. GraphRAG (Microsoft v2 / LightRAG / HippoRAG 2 / KAG) joins the three-round committee via RRF, and typed chunks enable reranking by graph distance.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in the ingestion stage&lt;/a> — the retrieval microservice consumes the vector store that this architecture keeps in sync.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka and a datalake&lt;/a> — the streaming transport that keeps fresh the index this post&amp;rsquo;s retrieval operates over.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production, May 2026&lt;/a> — the forensic tour of the request crosses retrieval; this post details what happens inside that box.&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> — the retrieval&amp;rsquo;s Recall@k and nDCG@k are metrics the eval gate can use as a promotion criterion. If they drop, the adapter deploy is blocked.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning: DVC, lakeFS and the challenge of a reproducible golden dataset&lt;/a> — the retrieval golden eval (queries with relevance-labelled chunks) is one of the four data artefacts worth versioning separately.&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 for LLMs&lt;/a> — output filters are applied after the LLM, but retrieval also receives adversarial queries worth filtering beforehand (prompt injection via the query). The reranker is also a natural point for discarding chunks with sensitive material that slipped into the corpus.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> — the downstream LLM and the light LLMs for rewriter/HyDE are served with the same engine.&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> — spec sheets for Qdrant, Elasticsearch, TEI, vLLM, Langfuse, Phoenix.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/semantic-cache-rag-receptionist-photographic-memory/">Semantic cache in RAG: the receptionist with a photographic memory&lt;/a> — the middleware placed before this retrieval; when the query is semantically similar to one already answered (cosine ≥ θ), the reranking committee never even fires.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evaluating-rag-ragas-golden-dataset-metrics/">Evaluating a RAG without fooling yourself: RAGAS, the golden dataset and the four metrics that matter&lt;/a> — context precision and context recall measure exactly the quality of this three-round committee; faithfulness measures what the LLM does with the resulting top-5.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/function-calling-tool-augmented-retrieval-detective-knows-which-file/">Function calling and tool-augmented retrieval: the detective who knows which file to ask for&lt;/a> — the retriever in this post is the &lt;code>vector_search&lt;/code> tool the LLM invokes in the ReAct pattern; the three-layer pipeline described here runs every time the agent picks that tool.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>BEIR&lt;/strong>: Thakur et al. (2021). &amp;ldquo;BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models.&amp;rdquo; NeurIPS 2021 Datasets and Benchmarks Track. &lt;a href="https://github.com/beir-cellar/beir">https://github.com/beir-cellar/beir&lt;/a>&lt;/li>
&lt;li>&lt;strong>SPLADE&lt;/strong>: Formal et al. (2021). &amp;ldquo;SPLADE: Sparse Lexical and Expansion Model for First Stage Ranking.&amp;rdquo; SIGIR 2021. &lt;a href="https://arxiv.org/abs/2107.05720">https://arxiv.org/abs/2107.05720&lt;/a>&lt;/li>
&lt;li>&lt;strong>ColBERT-v2&lt;/strong>: Santhanam et al. (2022). &amp;ldquo;ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction.&amp;rdquo; NAACL 2022. &lt;a href="https://arxiv.org/abs/2112.01488">https://arxiv.org/abs/2112.01488&lt;/a>&lt;/li>
&lt;li>&lt;strong>Reciprocal Rank Fusion&lt;/strong>: Cormack, Clarke, Buettcher (2009). &amp;ldquo;Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods.&amp;rdquo; SIGIR 2009. &lt;a href="https://dl.acm.org/doi/10.1145/1571941.1572114">https://dl.acm.org/doi/10.1145/1571941.1572114&lt;/a>&lt;/li>
&lt;li>&lt;strong>HyDE&lt;/strong>: Gao et al. (2022). &amp;ldquo;Precise Zero-Shot Dense Retrieval without Relevance Labels.&amp;rdquo; &lt;a href="https://arxiv.org/abs/2212.10496">https://arxiv.org/abs/2212.10496&lt;/a>&lt;/li>
&lt;li>&lt;strong>Step-back prompting&lt;/strong>: Zheng et al. (2023). &amp;ldquo;Take a Step Back: Evoking Reasoning via Abstraction in Large Language Models.&amp;rdquo; &lt;a href="https://arxiv.org/abs/2310.06117">https://arxiv.org/abs/2310.06117&lt;/a>&lt;/li>
&lt;li>&lt;strong>BGE-M3&lt;/strong>: Chen et al. (2024). &amp;ldquo;BGE M3-Embedding: Multi-Lingual, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation.&amp;rdquo; &lt;a href="https://arxiv.org/abs/2402.03216">https://arxiv.org/abs/2402.03216&lt;/a>&lt;/li>
&lt;li>&lt;strong>MTEB leaderboard&lt;/strong>: &lt;a href="https://huggingface.co/spaces/mteb/leaderboard">https://huggingface.co/spaces/mteb/leaderboard&lt;/a> — útil como punto de partida, peligroso como criterio único (overfitting al benchmark, dataset contamination).&lt;/li>
&lt;li>&lt;strong>TEI (Text Embeddings Inference)&lt;/strong>: &lt;a href="https://github.com/huggingface/text-embeddings-inference">https://github.com/huggingface/text-embeddings-inference&lt;/a> — el motor de serving de HuggingFace para embedders y rerankers de tamaño pequeño-mediano. Endpoint OpenAI-compatible.&lt;/li>
&lt;li>&lt;strong>Qdrant hybrid search&lt;/strong>: &lt;a href="https://qdrant.tech/documentation/concepts/hybrid-queries/">https://qdrant.tech/documentation/concepts/hybrid-queries/&lt;/a> — implementación nativa de RRF y multi-vector queries.&lt;/li>
&lt;li>&lt;strong>RAGatouille&lt;/strong>: &lt;a href="https://github.com/AnswerDotAI/RAGatouille">https://github.com/AnswerDotAI/RAGatouille&lt;/a> — wrapper Python para ColBERT-v2 que reduce drásticamente la curva de entrada.&lt;/li>
&lt;li>&lt;strong>Cohere Rerank 3&lt;/strong>: &lt;a href="https://docs.cohere.com/docs/rerank-2">https://docs.cohere.com/docs/rerank-2&lt;/a> — referencia técnica del reranker comercial multilingüe líder.&lt;/li>
&lt;/ul></description></item><item><title>RAG corpus curation: the active librarian who decides what goes in, what goes out and what gets signed</title><link>https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/</link><pubDate>Mon, 25 May 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/</guid><description>&lt;blockquote>
&lt;p>This is the &lt;strong>curation&lt;/strong> layer inside stage 1 (Data) of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a>. It complements the other Data posts: &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">dataset versioning&lt;/a> for the four versionable artefacts, &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant ingestion in microservices&lt;/a> for the outbox + CDC pattern, and &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka and a datalake&lt;/a> for streaming transport. This post is not about moving data: it is about what to do with data before you let a model read it.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A RAG system serving mediocre answers is rarely the retriever&amp;rsquo;s fault, nor the model&amp;rsquo;s. The root cause is usually the corpus: three nearly identical versions of the same PDF that make top-k return the same thing three times, an old manual nobody removed that contradicts the current one, a free-text field with customer numbers that the model quotes verbatim, a scanned PDF with dirty OCR that the chunker cut in the middle of a sentence. None of that is fixed by changing the model, the embedder, the reranker or the prompt. It is fixed by curating the corpus. This post takes apart the five operational layers of curation (schema-validated ingest, three-level deduplication, PII anonymisation measured with precision/recall, anti-contamination against the golden eval set, chunk→trace lineage), the minimum maths needed to avoid fooling yourself, the 2026 stack (Presidio, Unstructured, Argilla, LangChain text splitters, OpenLineage, Marquez, Great Expectations), the seven traps that reduce the stage to theatre, and the on-premise hardware to sustain all of it without sending anything sensitive to external APIs.&lt;/p>
&lt;h2 id="the-analogy-the-active-librarian">The analogy: the active librarian&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="RAG corpus curation as an active librarian">
&lt;style>
.lbox{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.lhead{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.lstage{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.lblt{font:600 13px sans-serif;fill:#222}
.lsub{font:400 11px sans-serif;fill:#555}
.larr{stroke:#666;stroke-width:1.5;fill:none;marker-end:url(#mc1)}
.lgate{fill:#ffd76b;stroke:#444;stroke-width:1.6;rx:6}
.lrej{fill:#f4b8b8;stroke:#a44;stroke-width:1.4;rx:6}
&lt;/style>
&lt;defs>&lt;marker id="mc1" 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="180" height="60" class="lhead"/>
&lt;text x="110" y="44" text-anchor="middle" class="lblt">Document arrives&lt;/text>
&lt;text x="110" y="62" text-anchor="middle" class="lsub">PDF, HTML, ticket, manual&lt;/text>
&lt;text x="110" y="76" text-anchor="middle" class="lsub">(book offered to the librarian)&lt;/text>
&lt;rect x="240" y="20" width="160" height="60" class="lstage"/>
&lt;text x="320" y="44" text-anchor="middle" class="lblt">1 · Schema check&lt;/text>
&lt;text x="320" y="62" text-anchor="middle" class="lsub">Great Expectations&lt;/text>
&lt;text x="320" y="76" text-anchor="middle" class="lsub">required fields + types&lt;/text>
&lt;rect x="440" y="20" width="160" height="60" class="lstage"/>
&lt;text x="520" y="44" text-anchor="middle" class="lblt">2 · Dedup&lt;/text>
&lt;text x="520" y="62" text-anchor="middle" class="lsub">exact + near + semantic&lt;/text>
&lt;text x="520" y="76" text-anchor="middle" class="lsub">hash + MinHash + cosine&lt;/text>
&lt;rect x="640" y="20" width="120" height="60" class="lstage"/>
&lt;text x="700" y="44" text-anchor="middle" class="lblt">3 · PII&lt;/text>
&lt;text x="700" y="62" text-anchor="middle" class="lsub">Presidio + recall&lt;/text>
&lt;text x="700" y="76" text-anchor="middle" class="lsub">measured vs golden&lt;/text>
&lt;path class="larr" d="M200,50 L240,50"/>
&lt;path class="larr" d="M400,50 L440,50"/>
&lt;path class="larr" d="M600,50 L640,50"/>
&lt;rect x="100" y="130" width="220" height="60" class="lstage"/>
&lt;text x="210" y="154" text-anchor="middle" class="lblt">4 · Anti-contamination&lt;/text>
&lt;text x="210" y="172" text-anchor="middle" class="lsub">cross-check against golden eval set&lt;/text>
&lt;text x="210" y="186" text-anchor="middle" class="lsub">reject token-by-token overlaps&lt;/text>
&lt;rect x="360" y="130" width="220" height="60" class="lstage"/>
&lt;text x="470" y="154" text-anchor="middle" class="lblt">5 · Lineage emit&lt;/text>
&lt;text x="470" y="172" text-anchor="middle" class="lsub">OpenLineage event with source,&lt;/text>
&lt;text x="470" y="186" text-anchor="middle" class="lsub">hash, schema_version, embedder&lt;/text>
&lt;rect x="600" y="130" width="160" height="60" class="lgate"/>
&lt;text x="680" y="154" text-anchor="middle" class="lblt">Gate&lt;/text>
&lt;text x="680" y="172" text-anchor="middle" class="lsub">passes all 5 layers →&lt;/text>
&lt;text x="680" y="186" text-anchor="middle" class="lsub">index in the vector store&lt;/text>
&lt;path class="larr" d="M700,86 L210,124"/>
&lt;path class="larr" d="M700,86 L470,124"/>
&lt;path class="larr" d="M700,86 L680,124"/>
&lt;rect x="80" y="240" width="280" height="60" class="lbox"/>
&lt;text x="220" y="264" text-anchor="middle" class="lblt">Accept → living corpus&lt;/text>
&lt;text x="220" y="282" text-anchor="middle" class="lsub">chunks with metadata, embeddings computed,&lt;/text>
&lt;text x="220" y="296" text-anchor="middle" class="lsub">indexed with dataset_hash in metadata&lt;/text>
&lt;rect x="400" y="240" width="280" height="60" class="lrej"/>
&lt;text x="540" y="264" text-anchor="middle" class="lblt">Reject → auditable quarantine&lt;/text>
&lt;text x="540" y="282" text-anchor="middle" class="lsub">rejection reason + diff vs previous version&lt;/text>
&lt;text x="540" y="296" text-anchor="middle" class="lsub">available for human review&lt;/text>
&lt;path class="larr" d="M580,196 L220,236"/>
&lt;path class="larr" d="M700,196 L540,236"/>
&lt;text x="390" y="338" text-anchor="middle" class="lsub" style="font-style:italic;">The active librarian: pass 5 layers or stay out. Nothing is accepted because "it was already there".&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>A serious librarian does not accept books by the kilo. When someone offers a new volume:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>They look at the spine, the ISBN and the stamp&lt;/strong>: is it legible? Is it catalogued correctly? Does it belong to a recognised collection? Without valid metadata, it does not get in. This is the &lt;strong>schema check&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>They check whether they already hold a copy&lt;/strong>: is it exactly the same book? Is it a later edition of the same one? Is it a translated version of something already on the shelves? If so, they decide explicitly what to do (replace, archive the old one, withdraw both from lending). This is &lt;strong>dedup&lt;/strong> at its three levels.&lt;/li>
&lt;li>&lt;strong>They flag what is restricted&lt;/strong>: if the book contains identifiable personal data, some pages cannot be lent as they are, so they must be redacted, anonymised or moved to the reserved section. This is &lt;strong>PII anonymisation&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>They verify it is not this year&amp;rsquo;s final exam book&lt;/strong>: if it is, it stays out of the public collection until the syllabus changes, because if students can consult it the exam stops measuring what it is meant to measure. This is &lt;strong>anti-contamination&lt;/strong> against the golden eval set.&lt;/li>
&lt;li>&lt;strong>They write it into the register&lt;/strong>: this book, this edition, this provenance, this date, this person who approved the entry. This is &lt;strong>lineage&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>If the book passes all five, it joins the collection. If it fails any of them, it goes to an &lt;strong>auditable quarantine shelf&lt;/strong> with the reason for rejection. The difference between a good collection and a mediocre one is not size: it is how much discipline you apply across the five layers, every day, to every new book that arrives.&lt;/p>
&lt;p>A RAG corpus is exactly that. The only differences are scale (thousands or millions of documents per month) and the fact that the &amp;ldquo;readers&amp;rdquo; are LLMs that cannot tell a duplicate from a reinforced truth, nor a PII value from a synthetic example, nor a contaminated fragment from an authentic one.&lt;/p>
&lt;h2 id="the-four-data-artefacts-and-where-the-rag-corpus-fits">The four data artefacts and where the RAG corpus fits&lt;/h2>
&lt;p>Before going down to the five layers it is worth being clear about which corpus we are curating. The Data stage of the pipeline manages four distinct artefacts, each with its own discipline. The &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning post&lt;/a> lists them; here I reorder them from a curation perspective:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Artefact&lt;/th>
&lt;th>Who consumes it&lt;/th>
&lt;th>Dominant curation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Training dataset&lt;/strong>&lt;/td>
&lt;td>Tune (fine-tuning the model or an adapter)&lt;/td>
&lt;td>aggressive dedup + quality filters + label balancing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RAG corpus&lt;/strong>&lt;/td>
&lt;td>Deploy (retrieval at request time)&lt;/td>
&lt;td>the 5 layers in this post&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Golden eval set&lt;/strong>&lt;/td>
&lt;td>Eval (promotion gates)&lt;/td>
&lt;td>strict hold-out + stratification + maintenance driven by incidents&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Retrain enriched dataset&lt;/strong>&lt;/td>
&lt;td>Retrain (closing the loop)&lt;/td>
&lt;td>production feedback + human triage&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;strong>RAG corpus&lt;/strong> is the most volatile of the four and the one most exposed to the end user: every answer the system serves literally contains fragments of it. A duplicate in the training dataset degrades learning but ends up buried in the weights; a duplicate in the RAG corpus shows up in today&amp;rsquo;s answer and tomorrow&amp;rsquo;s. That justifies the extra discipline that follows.&lt;/p>
&lt;h2 id="layer-1--schema-validated-ingest">Layer 1 — Schema-validated ingest&lt;/h2>
&lt;p>Every piece entering the corpus has to arrive with &lt;strong>structured metadata validated against a schema&lt;/strong>. This is not bureaucracy: it is the only way to make the following layers (dedup, PII, lineage) work without friction.&lt;/p>
&lt;p>The canonical pattern is to define a schema in JSON Schema or Pydantic that every document must satisfy:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">CorpusDocument&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BaseModel&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">source_system&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># e.g. &amp;#34;confluence&amp;#34;, &amp;#34;salesforce&amp;#34;, &amp;#34;manual_pdf&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">source_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># unique ID in the source system&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">version&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># document version (semver or date)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">language&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># ISO 639-1: &amp;#34;es&amp;#34;, &amp;#34;en&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">title&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">body&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">captured_at&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">datetime&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">captured_by&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># pipeline or human&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sensitivity&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Literal&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;public&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;internal&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;restricted&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">schema_version&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># version of the schema itself, not of the document&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Any piece that fails this contract is rejected at ingest and never reaches the following layers. Validation is done with &lt;strong>Great Expectations&lt;/strong> (declarative suites), &lt;strong>Pandera&lt;/strong> (more pythonic, integrates with pandas) or &lt;strong>Soda&lt;/strong> (oriented towards continuous data quality). The choice is a matter of style; what matters is:&lt;/p>
&lt;ul>
&lt;li>Validation suites live in code and are versioned with the pipeline, not in a notebook off to one side.&lt;/li>
&lt;li>Rejection generates an auditable event (quarantine) with the specific schema failure as its reason, not a log line lost in stdout.&lt;/li>
&lt;li>The schema itself is versioned. When it changes, previous documents are reprocessed or explicit backward compatibility is maintained.&lt;/li>
&lt;/ul>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka post&lt;/a> covers the &lt;strong>Schema Registry&lt;/strong> pattern (Confluent Schema Registry or Apicurio) that materialises this in streaming: every message on the topic is validated against the registered schema before propagating downstream. For batch or pull, Great Expectations is the equivalent.&lt;/p>
&lt;p>&lt;strong>Common trap&lt;/strong>: leaving the &lt;code>body&lt;/code> field free with no further validation. You have to tighten it up with minimum/maximum length (a PDF that yields 12 characters after extraction is almost certainly broken), valid encoding (UTF-8 with no control characters), proportion of alphanumeric characters (dirty OCR returns a soup of symbols). These are simple rules that filter 80% of the noise with no need for AI.&lt;/p>
&lt;h2 id="layer-2--three-level-deduplication">Layer 2 — Three-level deduplication&lt;/h2>
&lt;p>The most expensive and most silent mistake in a RAG corpus is the duplicate. A document that appears three times in the corpus makes retrieval&amp;rsquo;s top-k return it three times, wasting two slots and reinforcing a single source. The LLM reads it as if three independent sources agreed, when in fact it is the same thing repeated.&lt;/p>
&lt;p>Deduplication is done at three levels, in this order by cost:&lt;/p>
&lt;h3 id="level-a--exact-dedup-sha-256-hash">Level A — Exact dedup (SHA-256 hash)&lt;/h3>
&lt;p>Compute the hash of the normalised content (trim, lower-case where applicable, remove redundant whitespace) and compare it against an index of already ingested hashes. On a match, discard or replace. Cost: \(O(1)\) per document. It catches literal duplicates (the same PDF uploaded twice, two byte-for-byte copies of the same HTML).&lt;/p>
&lt;h3 id="level-b--near-duplicate-minhash--lsh">Level B — Near-duplicate (MinHash + LSH)&lt;/h3>
&lt;p>Nearly identical documents with minor differences (a different header, an updated date, one version in Castilian and another in Galician with minimal changes). The canonical algorithm is &lt;strong>MinHash with Locality-Sensitive Hashing (LSH)&lt;/strong>, which approximates Jaccard similarity over shingles of k tokens. For n documents, comparing all against all is \(O(n^2)\), which is unworkable for large corpora. LSH cuts the cost to \(O(n)\) most-likely buckets.&lt;/p>
&lt;p>A typical threshold is Jaccard ≥ 0.80 over shingles of 5 tokens. The standard libraries are &lt;code>datasketch&lt;/code> (Python, MIT) or &lt;code>dedup&lt;/code> (Python, MIT). A numerical example: for 1 M short documents (300 tokens each), &lt;code>datasketch.MinHashLSH&lt;/code> with 128 permutations and threshold 0.8 takes about 2 GB of RAM and processes the full corpus in around 30 minutes on a modern CPU. The fraction of duplicates found in a real enterprise corpus is usually between 5% and 25%; removing them cuts storage and improves retrieval quality at the same time.&lt;/p>
&lt;h3 id="level-c--semantic-dedup-cosine-over-embeddings">Level C — Semantic dedup (cosine over embeddings)&lt;/h3>
&lt;p>Documents saying the same thing in different words, such as paraphrases, translations or rewritten versions, are not caught by MinHash. This is where semantic similarity comes in: compute the embedding of each document and compare the cosine between pairs.&lt;/p>
&lt;p>The problem is quadratic cost: for n documents, computing every pair is \(O(n^2)\). For n = 1 M and 768-dimensional embeddings (a typical model such as &lt;code>BAAI/bge-base-en-v1.5&lt;/code>), that is 5×10^11 dot products, which is unworkable. The solution is the same idea as LSH but over dense vectors: &lt;strong>HNSW&lt;/strong> (Hierarchical Navigable Small World) or &lt;strong>IVF&lt;/strong> (Inverted File) to build an approximate search index. For each new document you run a k-NN query against the index and examine only the k nearest neighbours.&lt;/p>
&lt;p>A sensible threshold for treating something as a semantic duplicate: &lt;strong>cosine ≥ 0.95&lt;/strong>. Below 0.95 the documents are related but distinct; above it, they are almost always the same information rewritten. The exact threshold is calibrated by observing precision/recall over a human-annotated sample; 100 pairs confirmed by a reviewer is reasonable for setting it.&lt;/p>
&lt;p>A numerical example: with &lt;code>qdrant&lt;/code> or &lt;code>pgvector&lt;/code> as the HNSW index and k=10 neighbours per query, deduplicating 1 M documents against the existing corpus takes on the order of 2-4 hours on an RTX 4090 (including embedding computation). If the embedder is self-hosted with vLLM, the cost per token is negligible against compute time.&lt;/p>
&lt;h3 id="policy-on-what-to-do-with-a-duplicate">Policy on what to do with a duplicate&lt;/h3>
&lt;p>Detection is not enough, you have to decide. Three common policies, in order of complexity:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Drop&lt;/strong>: discard the more recent one, keep the older one. Simple, with no extra lineage.&lt;/li>
&lt;li>&lt;strong>Replace&lt;/strong>: discard the old one, index the new one. More volatility but it reflects the update.&lt;/li>
&lt;li>&lt;strong>Merge with provenance&lt;/strong>: mark the new one as a &amp;ldquo;shadow&amp;rdquo; of the old one, keep both in lineage but index only one. Better for regulated audit.&lt;/li>
&lt;/ul>
&lt;p>The policy has to be explicit and applied uniformly, not an ad-hoc decision per document.&lt;/p>
&lt;h2 id="layer-3--pii-anonymisation-with-measured-precisionrecall">Layer 3 — PII anonymisation with measured precision/recall&lt;/h2>
&lt;p>This layer is the one that most easily turns into theatre. The typical mistake: install Presidio, run it over the corpus, assume the output is clean. Without measuring the detector&amp;rsquo;s precision and recall against an annotated golden set, you know nothing.&lt;/p>
&lt;p>A PII detector can fail in two ways:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>False negative&lt;/strong> (low recall): it does not detect a national ID written as &amp;ldquo;12345678-A&amp;rdquo; because your model was trained on the &lt;code>12345678A&lt;/code> format without a hyphen. The RAG serves personal data unredacted.&lt;/li>
&lt;li>&lt;strong>False positive&lt;/strong> (low precision): it redacts a number from a configuration manual thinking it is a phone number. The RAG loses useful information.&lt;/li>
&lt;/ul>
&lt;p>Both are problems; regulation (GDPR, ENS, NIS2) penalises the first, and user experience degrades with the second. The acceptable ratio depends on the domain. In medical data, practically zero false negatives is non-negotiable; in internal technical documentation you can tolerate more recall in exchange for less precision.&lt;/p>
&lt;p>The standard metric is &lt;strong>F1 over an annotated golden set&lt;/strong>:&lt;/p>
\[
\text{precision} = \frac{TP}{TP + FP}, \quad \text{recall} = \frac{TP}{TP + FN}, \quad F_1 = 2 \cdot \frac{\text{precision} \cdot \text{recall}}{\text{precision} + \text{recall}}
\]
&lt;p>To build the PII golden set, annotate around 200 documents by hand with every entity marked (national ID, IBAN, email, phone, address, personal name). Then run the detector and compute the metrics per category, not just aggregated, because a global F1 of 0.90 can hide a recall of 0.55 on IBANs.&lt;/p>
&lt;p>&lt;strong>2026 stack&lt;/strong> for this layer:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Microsoft Presidio&lt;/strong> (MIT, Microsoft): the most complete OSS option. Configurable detectors, recognises around 50 entities by default, extensible with your own regex patterns or with fine-tuned NER models.&lt;/li>
&lt;li>&lt;strong>spaCy NER&lt;/strong> (MIT, Explosion AI): a base for custom detectors; useful when Presidio does not cover a domain entity.&lt;/li>
&lt;li>&lt;strong>Llama Guard 4&lt;/strong> (Llama Community License, Meta): a safety classifier that also detects PII in one pass, an option when you already have a GPU for inference and prefer a single pass.&lt;/li>
&lt;li>&lt;strong>DataFog&lt;/strong> (Apache 2.0): a more recent alternative, specialised in streaming pipelines.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Recommended hybrid pattern&lt;/strong>: Presidio for rule-based + regex detection (fast, deterministic) → Llama Guard as a second pass over what Presidio did not flag (an ensemble that raises recall without killing throughput). This is measured and reported as aggregate and per-category F1 on every release of the detector.&lt;/p>
&lt;p>&lt;strong>Common fallacy&lt;/strong>: trusting that a detector with F1 0.95 &amp;ldquo;is very good&amp;rdquo;. If you have 1 M documents and each contains on average 1 PII entity, F1 0.95 means 50,000 mishandled entities (between false positives and negatives). With sensitive data you have to design so that false negatives go to human quarantine, not to the public corpus.&lt;/p>
&lt;h2 id="layer-4--anti-contamination-against-the-golden-eval-set">Layer 4 — Anti-contamination against the golden eval set&lt;/h2>
&lt;p>If the RAG corpus contains fragments of the golden eval set, Eval metrics measure memorisation. The model returns the exact answer because it has it literally in its context, not because it generalised anything. Deploy then promotes models that shine in the exam and fail in production.&lt;/p>
&lt;p>This layer is the easiest to implement and the easiest to forget:&lt;/p>
&lt;ol>
&lt;li>The &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">golden eval set&lt;/a> has its own versioned hash.&lt;/li>
&lt;li>Before indexing any new document into the RAG corpus, run a token-by-token overlap check (or by shingles, similar to MinHash) against the golden set.&lt;/li>
&lt;li>If overlap exceeds a threshold (typically ≥ 30% of 5-token n-grams), the document is not indexed. It stays in quarantine flagged as &amp;ldquo;contamination risk vs golden_v12&amp;rdquo;.&lt;/li>
&lt;li>A human reviews the rejections. Sometimes they are false positives (a short quotation, a boilerplate sentence). Sometimes they are real contamination that a supplier introduced without realising.&lt;/li>
&lt;/ol>
&lt;p>The deeper reason: the RAG corpus and the golden set are enemies by design. The golden set measures how well the system generalises to questions it has not seen. If those questions are in the RAG, the system &amp;ldquo;sees&amp;rdquo; them on every query. The metric stops measuring generalisation.&lt;/p>
&lt;p>This check is computationally trivial, a hash join over n-grams. The difficulty is keeping it up: every time the golden set changes (monthly or quarterly), the full corpus has to be re-validated against the new golden set. Without that discipline, contamination sneaks in through the back door when someone updates the golden set with real cases that the RAG was already serving.&lt;/p>
&lt;h2 id="layer-5--end-to-end-lineage-from-document-to-trace">Layer 5 — End-to-end lineage: from document to trace&lt;/h2>
&lt;p>The last layer is the one that closes the auditable chain. Every chunk indexed in the vector store carries metadata that lets you answer the forensic question:&lt;/p>
&lt;blockquote>
&lt;p>&amp;ldquo;The system generated this answer on 14 March at 16:23. Which exact document did the quoted fragment come from? When did that document enter the corpus? Which version of the embedder processed it? Who approved its ingest?&amp;rdquo;&lt;/p>
&lt;/blockquote>
&lt;p>Without lineage, that question is unanswerable. With lineage done properly, it is four queries.&lt;/p>
&lt;p>The canonical pattern:&lt;/p>
&lt;ul>
&lt;li>Every indexed chunk carries in its metadata: &lt;code>source_system&lt;/code>, &lt;code>source_id&lt;/code>, &lt;code>document_version&lt;/code>, &lt;code>chunk_index&lt;/code>, &lt;code>embedder_version&lt;/code>, &lt;code>dataset_hash&lt;/code>, &lt;code>ingested_at&lt;/code>, &lt;code>ingested_by&lt;/code>, &lt;code>schema_version&lt;/code>.&lt;/li>
&lt;li>Every RAG answer in production emits a trace span that includes the retrieved &lt;code>chunk_id&lt;/code> values.&lt;/li>
&lt;li>The central tracing system (&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">Langfuse, Phoenix or OpenLLMetry&lt;/a>) joins &lt;code>chunk_id&lt;/code> → chunk metadata → document metadata → corpus &lt;code>dataset_hash&lt;/code> → embedder version → and so on.&lt;/li>
&lt;/ul>
&lt;p>The tools that standardise this glue are &lt;strong>OpenLineage&lt;/strong> (Apache 2.0, LF AI &amp;amp; Data) and &lt;strong>Marquez&lt;/strong> (Apache 2.0, its server implementation). They define a lineage event schema that interoperates between systems; an ingest job emits an event &amp;ldquo;produced corpus_v12.3 from source X with embedder bge-base-v1.5&amp;rdquo;; a retrieval job emits &amp;ldquo;consumed corpus_v12.3 with query Q produced response R&amp;rdquo;. The graph is reconstructed automatically.&lt;/p>
&lt;p>This layer is the only way to pass real audits under regulations such as the EU AI Act, GDPR or ENS, where traceability of which data went into which answer is a requirement, not an option. Without it, the answer &amp;ldquo;we do not know which document this came from&amp;rdquo; is not acceptable, and it is the default answer if lineage is not built from day one.&lt;/p>
&lt;h2 id="the-maths-that-matter">The maths that matter&lt;/h2>
&lt;p>Beyond dedup thresholds and PII F1 scores, there are three mathematical pieces that any serious team ends up using.&lt;/p>
&lt;p>&lt;strong>Chunk size vs retrieval quality&lt;/strong>. Chunk size affects retrieval quality in a non-monotonic way: chunks that are too small fragment ideas (retrieval returns a piece without context), and chunks that are too large dilute the signal (the embedding mixes several topics and similarity drops). The empirical sweet spot for technical text in 2026 is between &lt;strong>256 and 768 tokens per chunk&lt;/strong>, with &lt;strong>15-25% overlap&lt;/strong> between contiguous chunks to preserve continuity.&lt;/p>
&lt;p>Numerically, for a corpus of 1 M documents with a mean length of 2,000 tokens, chunking at 512 tokens with overlap 100 gives \(\frac{2000}{512 - 100} \approx 5\) chunks per document, a total of roughly 5 M indexed chunks. With 768-dimensional embeddings in &lt;code>float32&lt;/code>, that occupies \(5 \cdot 10^6 \cdot 768 \cdot 4 \approx 15\) GB of vector memory, manageable in any modern vector store.&lt;/p>
&lt;p>&lt;strong>PII golden set coverage&lt;/strong>. To know whether the annotated PII golden set is representative enough, compute the proportion of categories covered: if your golden set of 200 documents has 5 IBAN examples and production has 12,000 IBANs per day, the measured F1 on IBANs is statistical noise. Rule of thumb: &lt;strong>a minimum of 30 examples per category&lt;/strong> for per-category metrics to mean anything.&lt;/p>
&lt;p>&lt;strong>Cost of re-embedding when rotating the model&lt;/strong>. Changing the embedder invalidates the whole index. For a corpus of 5 M chunks with a model such as &lt;code>BAAI/bge-base-en-v1.5&lt;/code> (768 dim, around 110 M parameters) served in vLLM on 1× H100, throughput is on the order of 8,000-15,000 chunks/second. Full re-embedding: about 5-10 minutes. For a larger embedder (&lt;code>bge-large&lt;/code>, 1024 dim, around 335 M parameters), a factor of 3× worse, around 15-30 minutes. The bottleneck is usually vector store I/O, not GPU compute. The &lt;strong>dual-index&lt;/strong> pattern, keeping the old index serving while the new one is built and doing an atomic swap at the end, avoids downtime and allows rollback.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For an on-premise deployment that keeps all curation in-house without sending data to external APIs:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>RTX 4090 (24 GB)&lt;/strong>: covers layer 1 (the schema check with Great Expectations is CPU-bound), layer 2 levels A and B (hash + MinHash are CPU-bound), and layer 2 level C semantic dedup with a &lt;code>bge-base&lt;/code>-class embedder (8-15k chunks/s, enough for corpora of up to 5-10 M chunks in hours). For Presidio in NER mode (layer 3) it runs comfortably. It is the sensible GPU for the whole curation pipeline on a mid-size corpus.&lt;/li>
&lt;li>&lt;strong>Generic 4×H100 SXM configuration (320 GB total, NVLink)&lt;/strong>: only needed if the corpus exceeds around 50 M chunks or if you want frequent re-embeddings with large models (&lt;code>bge-large&lt;/code>, &lt;code>e5-mistral&lt;/code>). In practice, two GPUs serve the embedder at TP=2 with throughput above 50k chunks/s, and the other two go to the PII judge (Llama Guard 4) or to serving the main inference model. Capacity for corpora of hundreds of millions of chunks.&lt;/li>
&lt;/ul>
&lt;p>The stubborn arithmetic: with a 4090, corpus curation is an overnight job; with 4×H100, it is minutes. The decision depends on corpus size and on how often you rotate the embedder or the PII rules.&lt;/p>
&lt;h2 id="the-seven-traps-that-kill-this-stage">The seven traps that kill this stage&lt;/h2>
&lt;p>&lt;strong>Trap 1 — No validated schema at ingest.&lt;/strong> Malformed documents reach the chunker, the chunker slices them into nonsense, garbage embeddings enter the index. The RAG answer quotes incoherent text and nobody knows why.&lt;/p>
&lt;p>&lt;strong>Trap 2 — Dedup only at exact hash level.&lt;/strong> The corpus fills up with paraphrases and translations of the same document. Retrieval&amp;rsquo;s top-k returns the same source 3 times. The LLM reads it as three confirmations.&lt;/p>
&lt;p>&lt;strong>Trap 3 — PII detector without precision/recall measurement.&lt;/strong> Everyone assumes Presidio &amp;ldquo;works&amp;rdquo;. IBANs in non-standard formats slip through. The RAG serves personal data.&lt;/p>
&lt;p>&lt;strong>Trap 4 — Golden eval set contaminated with the corpus.&lt;/strong> Eval metrics measure memorisation. Promotions approve models that fail in real production.&lt;/p>
&lt;p>&lt;strong>Trap 5 — No lineage down to the chunk.&lt;/strong> The question &amp;ldquo;where did this quotation come from?&amp;rdquo; has no answer. Regulatory audit fails. Incidents cannot be investigated.&lt;/p>
&lt;p>&lt;strong>Trap 6 — Maintenance as a one-off event.&lt;/strong> The corpus is curated once when the system is initialised, and afterwards everyone assumes it is fine. Six months later the documents are out of date, the new PII rules are not applied retrospectively, dedup has not been re-run after adding new sources. The corpus degrades silently.&lt;/p>
&lt;p>&lt;strong>Trap 7 — Quarantine without human review.&lt;/strong> Rejected documents go into a table nobody looks at. False positives pile up, real contamination cases go uninvestigated, the team&amp;rsquo;s trust in curation erodes and the pressure to &amp;ldquo;relax the thresholds&amp;rdquo; begins.&lt;/p>
&lt;p>All seven are operational, not technical. Corpus curation does not break because of an algorithm bug: it breaks because discipline slackens. It is the exact equivalent of the kind of degradation that kills Eval suites, and in both cases the symptom is the same: the metrics improve or hold steady while the real experience gets worse.&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>Vector store versioning proper&lt;/strong>: an embedding index is not versioned like a raw dataset because it depends on the embedding model. Changing the embedder rewrites the whole index. It is a different animal with its own patterns (index branching, selective reembedding, recall-aware ANN parameters).&lt;/li>
&lt;li>&lt;strong>Streaming corpus updates with CDC&lt;/strong>: when the corpus has to be updated in near-real time from an OLTP system. The &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">Postgres + Qdrant ingestion post&lt;/a> covers the mechanics; still pending is the pattern for selectively invalidating chunks that depend on deleted rows.&lt;/li>
&lt;li>&lt;strong>Multi-tenant corpus isolation&lt;/strong>: how to set up a shared corpus versus one with per-tenant namespaces, with ACLs over individual chunks. Especially relevant for multi-customer RAG under data sovereignty constraints.&lt;/li>
&lt;li>&lt;strong>Federated corpus&lt;/strong>: corpora distributed across silos that the system queries without centralising the content. An emerging pattern for companies with several sites and cross-border restrictions.&lt;/li>
&lt;li>&lt;strong>Reranking-aware curation&lt;/strong>: how curation discipline changes when there is a reranker (Cohere Rerank, ColBERTv2, BGE-Reranker) reordering the top-k after retrieval. Some duplicates you would tolerate without a reranker become intolerable when the reranker pushes them up the ranking.&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/end-to-end-document-ingestion-pdf-to-indexed-chunk/">End-to-end document ingestion: from PDF to indexed chunk&lt;/a> — how curation materialises in the ingestion pipeline, from PDF to indexed chunk.&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> — Stage 1 (Data) and why curation is the most underrated sub-task in the whole chain.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings in 2026: the three families, the model zoo and the decision that matters&lt;/a> — which cartographer processes the chunks curated here to turn them into searchable vectors; embedder selection criteria (bge-m3 / Snowflake Arctic / Jina / Nomic / ColBERT) and storage cost.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/ontologies-knowledge-graphs-llmops-linnaean-nomenclature/">Ontologies and knowledge graphs in LLMOps&lt;/a> — the Linnaean nomenclature without which curation is left with ad-hoc categories; how the chunks in this post are typed against a TBox (FIBO / SNOMED / ENS / schema.org), validated with SHACL and enriched with metadata queryable in SPARQL.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production, May 2026&lt;/a> — the forensic tour crosses the corpus and the retrieved chunks; here are the criteria any chunk had to pass to be in production.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning: DVC, lakeFS and the reproducible golden dataset challenge&lt;/a> — the four data artefacts and why they are versioned separately. The RAG corpus is one of the four.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in the ingestion stage&lt;/a> — the microservices pattern that moves documents from source to vector store. The curation in this post plugs in between ingest and indexer.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka and a datalake&lt;/a> — streaming transport. Schema Registry materialises layer 1.&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> — the golden eval set is the RAG corpus&amp;rsquo;s &amp;ldquo;enemy by design&amp;rdquo;; layer 4 (anti-contamination) materialises the discipline between the two.&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: the contract that stops a five-word change sinking your system&lt;/a> — the &lt;code>prompt_id&lt;/code> travelling in the trace is the counterpart of the corpus &lt;code>dataset_hash&lt;/code> in lineage.&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 loop between the production incident and the adapter that fixes it&lt;/a> — the retrain enriched corpus also needs the five layers, with additional emphasis on human feedback.&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> — entries for Presidio, Unstructured, Argilla, Great Expectations, OpenLineage.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — the curation in this post is prevention at ingest; guardrails are mitigation at runtime when something slips through. Line 2 (retrieval GR) filters chunks with indirect prompt injection before they enter the LLM&amp;rsquo;s context.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001: the operations manual for an AI system&lt;/a> — the five curation layers in this post directly cover controls A.7.3 (acquisition), A.7.4 (quality), A.7.5 (provenance) and A.7.6 (preparation) of the AIMS Annex A.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Presidio&lt;/strong>: &lt;a href="https://microsoft.github.io/presidio/">https://microsoft.github.io/presidio/&lt;/a> — official docs, list of supported entities, guide to extending it with custom NER.&lt;/li>
&lt;li>&lt;strong>OpenLineage&lt;/strong>: &lt;a href="https://openlineage.io/">https://openlineage.io/&lt;/a> — event schema spec and libraries per language.&lt;/li>
&lt;li>&lt;strong>Marquez&lt;/strong>: &lt;a href="https://marquezproject.ai/">https://marquezproject.ai/&lt;/a> — server implementation of OpenLineage.&lt;/li>
&lt;li>&lt;strong>datasketch (MinHash + LSH)&lt;/strong>: &lt;a href="https://ekzhu.com/datasketch/">https://ekzhu.com/datasketch/&lt;/a> — the reference Python library for near-duplicate deduplication at scale.&lt;/li>
&lt;li>&lt;strong>Great Expectations&lt;/strong>: &lt;a href="https://docs.greatexpectations.io/">https://docs.greatexpectations.io/&lt;/a> — declarative data quality suites.&lt;/li>
&lt;li>&lt;strong>Unstructured&lt;/strong>: &lt;a href="https://docs.unstructured.io/">https://docs.unstructured.io/&lt;/a> — parsing and normalisation of heterogeneous documents (PDF, HTML, DOCX, eml) before chunking.&lt;/li>
&lt;li>&lt;strong>Argilla&lt;/strong>: &lt;a href="https://docs.argilla.io/">https://docs.argilla.io/&lt;/a> — human annotation UI for building the PII golden set and other calibration sets.&lt;/li>
&lt;li>&lt;strong>Llama Guard 4&lt;/strong>: Meta technical paper, multimodal safety classifier — useful as a second PII detection layer.&lt;/li>
&lt;li>&lt;strong>GDPR, EU AI Act, ENS, NIS2&lt;/strong> — the regulatory frameworks whose compliance depends, in practice, on the discipline of layers 3 (PII) and 5 (lineage). The CEN/CENELEC technical standards for conformity assessment of GenAI systems under the EU AI Act are still pending final publication.&lt;/li>
&lt;/ul></description></item><item><title>Evals for LLMs: the layer after tracing that decides whether your model performs or only looks like it does</title><link>https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/</link><pubDate>Mon, 25 May 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/</guid><description>&lt;blockquote>
&lt;p>This is &lt;strong>stage 3&lt;/strong> of the LLMOps pipeline. If you arrive without context on the full journey, the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a> describes where Eval sits between Tune and Deploy, and the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">anatomy of a production LLM request&lt;/a> shows a real eval blocking the promotion of an adapter.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Tracing is not evaluation. &lt;strong>Tracing tells you what happened; eval tells you whether what happened is any good.&lt;/strong> The two layers live in tools that overlap visually (Langfuse does both), but the discipline is different: tracing is continuous capture over real traffic; eval is controlled execution against a stable dataset, with metrics that have to &lt;strong>fail CI&lt;/strong> if they drop below a threshold. Without eval, the &lt;code>Tune → Deploy&lt;/code> loop closes blind: adapter v8 goes to production because the engineer &amp;ldquo;saw it answering well&amp;rdquo; on five examples. With eval done properly, v8 only gets in if it beats v7 across a battery of 500 curated cases, evaluated by a mix of heuristics, embeddings, an LLM judge calibrated against humans, and a sample of real traffic with human annotation. This post takes apart the mechanism, the mathematics that keep you honest, the real tools in 2026 and the pitfalls that turn it into theatre.&lt;/p>
&lt;h2 id="the-analogy-the-academic-examination-board">The analogy: the academic examination board&lt;/h2>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Eval as an academic examination board">
&lt;style>
.tbox{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.thead{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.tlbl{font:600 13px sans-serif;fill:#222}
.tsub{font:400 11px sans-serif;fill:#555}
.tarr{stroke:#666;stroke-width:1.5;fill:none;marker-end:url(#me1)}
.tgate{fill:#ffd76b;stroke:#444;stroke-width:1.6;rx:6}
&lt;/style>
&lt;defs>&lt;marker id="me1" 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="160" height="60" class="thead"/>
&lt;text x="100" y="44" text-anchor="middle" class="tlbl">Candidate&lt;/text>
&lt;text x="100" y="62" text-anchor="middle" class="tsub">model + adapter + prompt&lt;/text>
&lt;text x="100" y="76" text-anchor="middle" class="tsub">(the "thesis" being defended)&lt;/text>
&lt;rect x="240" y="20" width="200" height="60" class="tbox"/>
&lt;text x="340" y="44" text-anchor="middle" class="tlbl">Fixed syllabus&lt;/text>
&lt;text x="340" y="62" text-anchor="middle" class="tsub">versioned golden dataset&lt;/text>
&lt;text x="340" y="76" text-anchor="middle" class="tsub">(500 questions with expected answer)&lt;/text>
&lt;rect x="500" y="20" width="240" height="60" class="tbox"/>
&lt;text x="620" y="44" text-anchor="middle" class="tlbl">Mixed board&lt;/text>
&lt;text x="620" y="62" text-anchor="middle" class="tsub">heuristics + embeddings + LLM judge&lt;/text>
&lt;text x="620" y="76" text-anchor="middle" class="tsub">+ human sample (calibrated panel)&lt;/text>
&lt;path class="tarr" d="M180,50 L240,50"/>
&lt;path class="tarr" d="M440,50 L500,50"/>
&lt;rect x="40" y="130" width="180" height="60" class="tbox"/>
&lt;text x="130" y="154" text-anchor="middle" class="tlbl">Marks per category&lt;/text>
&lt;text x="130" y="172" text-anchor="middle" class="tsub">faithfulness, relevancy,&lt;/text>
&lt;text x="130" y="186" text-anchor="middle" class="tsub">format, toxicity, latency&lt;/text>
&lt;rect x="270" y="130" width="220" height="60" class="tbox"/>
&lt;text x="380" y="154" text-anchor="middle" class="tlbl">Aggregation + segmentation&lt;/text>
&lt;text x="380" y="172" text-anchor="middle" class="tsub">global mean, by language,&lt;/text>
&lt;text x="380" y="186" text-anchor="middle" class="tsub">by tenant, by question type&lt;/text>
&lt;rect x="540" y="130" width="200" height="60" class="tgate"/>
&lt;text x="640" y="154" text-anchor="middle" class="tlbl">Eval gate (pass mark)&lt;/text>
&lt;text x="640" y="172" text-anchor="middle" class="tsub">faithfulness ≥ 0.85 ∧ tox &amp;lt; 0.02&lt;/text>
&lt;text x="640" y="186" text-anchor="middle" class="tsub">∧ regression vs baseline &amp;lt; 2 pp&lt;/text>
&lt;path class="tarr" d="M620,86 L130,124"/>
&lt;path class="tarr" d="M620,86 L380,124"/>
&lt;path class="tarr" d="M620,86 L640,124"/>
&lt;rect x="40" y="240" width="320" height="60" class="tbox"/>
&lt;text x="200" y="264" text-anchor="middle" class="tlbl">Pass → promotion to production&lt;/text>
&lt;text x="200" y="282" text-anchor="middle" class="tsub">label `production` moves to the candidate&lt;/text>
&lt;rect x="400" y="240" width="320" height="60" class="tbox"/>
&lt;text x="560" y="264" text-anchor="middle" class="tlbl">Fail → back to Tune&lt;/text>
&lt;text x="560" y="282" text-anchor="middle" class="tsub">incident or regression stays in lineage&lt;/text>
&lt;path class="tarr" d="M580,196 L200,236"/>
&lt;path class="tarr" d="M700,196 L560,236"/>
&lt;/svg>
&lt;/div>
&lt;p>A doctoral candidate defends a thesis. They do not defend it before a single distracted professor: they defend it before a &lt;strong>mixed board&lt;/strong>, with a &lt;strong>syllabus fixed in advance&lt;/strong> (not improvised on the day), and with an &lt;strong>explicit pass mark&lt;/strong> that separates pass from fail. The board is not a single expert: it is a panel combining fast readers (the heuristics: does it have the requested format? does it include the citation?), semantic reviewers (the embeddings: does it resemble the expected answer?), an external assessor trained on the subject (the LLM judge: is it faithful to the context, is it relevant, does it sound coherent?), and human jury members on a &lt;strong>sample of the most sensitive cases&lt;/strong>. If the candidate does not reach the mark, they are not promoted: they go back and prepare the defence again.&lt;/p>
&lt;p>That analogy has three edges worth holding on to from the first minute:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The syllabus is fixed&lt;/strong>, but &lt;strong>it is actively updated when a new topic appears in the real world&lt;/strong> that the candidate must know. If it is not updated, the exam measures something increasingly distant from what happens outside.&lt;/li>
&lt;li>&lt;strong>The board has to be trained&lt;/strong>: an LLM judge not calibrated against humans is an examiner making up the criteria.&lt;/li>
&lt;li>&lt;strong>The pass mark is published beforehand&lt;/strong>: it is not decided after seeing the result, because then it is not a pass mark, it is a justification.&lt;/li>
&lt;/ol>
&lt;p>These three ideas run through the rest of the post. Every tool and every piece of mathematics that follows is, at bottom, a way of operationalising them.&lt;/p>
&lt;h2 id="the-mechanism-itself-four-layers-of-evaluators">The mechanism itself: four layers of evaluators&lt;/h2>
&lt;p>An eval suite in 2026 is made up of four layers that coexist. None replaces the others; each one measures what the rest cannot measure well, and leaves unmeasured what the rest do measure well.&lt;/p>
&lt;p>&lt;strong>Layer 1 — Deterministic heuristics.&lt;/strong> Rules that return &lt;code>true&lt;/code> or &lt;code>false&lt;/code> with no ambiguity: the output matches a regex, contains a specific entity, does not exceed a length, follows a valid JSON schema, respects a requested format (markdown, &lt;code>tool_call&lt;/code> function, mandatory citation). They are cheap, extremely fast, need neither judge nor embeddings, and catch the most frequent class of bug: the model answered in the wrong format. Their limit is obvious, in that they do not know whether the answer is &lt;strong>correct&lt;/strong>, only whether it is &lt;strong>well formed&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Layer 2 — Semantic metrics with embeddings.&lt;/strong> They compare the model&amp;rsquo;s output with an expected answer by computing cosine similarity between their embeddings, or by measuring whether a claim in the output is entailed by the retrieved context. They are cheap, deterministic given the embedding model, and very useful for detecting answers that drift in meaning but not in form. Their limit is equally clear: two answers can have high cosine similarity and say the opposite of each other (&amp;ldquo;the customer can cancel at any time&amp;rdquo; vs &amp;ldquo;the customer cannot cancel at any time&amp;rdquo; share 80% of their tokens).&lt;/p>
&lt;p>&lt;strong>Layer 3 — LLM-as-judge.&lt;/strong> A model, or an ensemble, evaluates the output of the model under test with a prompt designed to produce a score on a rubric. The canonical methods in 2026 are &lt;strong>G-Eval&lt;/strong> (chain-of-thought prompting with a calibrated numeric score), &lt;strong>Prometheus&lt;/strong> (open-source judge trained specifically for evals, reporting 0.897 correlation with humans in its v2.5 release from late 2025), and &lt;strong>panel-of-judges&lt;/strong> setups that average votes from three heterogeneous models to reduce bias. This layer captures nuances the previous two cannot see: is it faithful to the context? is it useful? is it safe? is it complete? Its limit is cost and the need for calibration, treated as a whole section below.&lt;/p>
&lt;p>&lt;strong>Layer 4 — Humans.&lt;/strong> Trained annotators who evaluate a &lt;strong>sample&lt;/strong> of the eval set, not all of it. They are the only layer with ultimate authority over the rubric: the LLM judge is calibrated against them, not the other way round. They are expensive (≈ 0.50–2.00 € per annotated sample when the domain is technical) and slow (a competent annotator produces 60–120 quality annotations per working day). The mistake teams make again and again is dropping this layer &amp;ldquo;because we have an LLM judge&amp;rdquo;; without humans there is no calibration, and without calibration the judge measures whatever it feels like.&lt;/p>
&lt;p>Normal operation of an eval gate combines all four: heuristics remove malformed outputs before spending judge tokens, embeddings filter out the manifestly irrelevant, the judge scores the rest, and humans annotate a sample every N runs to keep the judge calibrated.&lt;/p>
&lt;h2 id="the-golden-dataset-a-versioned-syllabus">The golden dataset: a versioned syllabus&lt;/h2>
&lt;p>The golden dataset is the most undervalued artefact in the pipeline. It is &lt;strong>the exam&lt;/strong>. If it is badly built, everything else, the best-calibrated judge in the world, the strictest gates, the fastest suite, measures noise. Covered at the top level in the &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning post&lt;/a> as one of the four artefacts to version differently; here we go into detail from the Eval perspective.&lt;/p>
&lt;p>The three properties a golden dataset has to satisfy are:&lt;/p>
&lt;p>&lt;strong>Stratified representativeness.&lt;/strong> The dataset has to cover the system&amp;rsquo;s real input space in proportions that reflect production. If 30% of real traffic is in German, 30% of the golden set has to be in German; if 12% of questions are about subscription cancellation, that category cannot be 60% of the eval set just because it was easy to annotate. Stratification stays auditable: each example carries tags (&lt;code>lang=de&lt;/code>, &lt;code>category=cancellation&lt;/code>, &lt;code>tenant_type=enterprise&lt;/code>, &lt;code>difficulty=hard&lt;/code>) and the suite reports metrics per segment, not just the aggregate.&lt;/p>
&lt;p>&lt;strong>Strict holdout, no contamination with training.&lt;/strong> This rule is so obvious that almost every team believes it is following it, and almost every team breaks it without noticing. If the golden eval set gets mixed with the fine-tuning dataset, because somebody did a badly built &lt;code>random_split&lt;/code>, because a purchased dataset is used by other vendors for training, because the LLM judge saw it during pretraining, then the metric stops measuring generalisation and starts measuring memorisation. The eval set hash is versioned separately (covered in the &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning post&lt;/a>) and a routine leakage check is run: if an example in the golden set matches one in training token for token, it raises an alert.&lt;/p>
&lt;p>&lt;strong>Reasoned sample size, not aspirational.&lt;/strong> How many examples are needed? There is a mathematical minimum for telling two models apart with confidence. If model A is right 80% of the time and model B 85% on the same set, the 95% confidence interval for that difference of proportions (unpaired) is:&lt;/p>
\[
\Delta p \pm 1{.}96 \cdot \sqrt{\frac{p_A(1-p_A) + p_B(1-p_B)}{n}}
\]
&lt;p>To distinguish 80% from 85% with 95% confidence (an interval that does not cross zero), you need n ≈ 700 examples. To distinguish 90% from 91% under the same criterion, the calculation gives n ≈ 6,500. A &amp;ldquo;we have 50 examples in the golden set and we see adapter v8 scoring 90%&amp;rdquo; means nothing statistically: the confidence interval is ±8 points. The field&amp;rsquo;s practical rule in 2026 is &lt;strong>a minimum of 300 examples to detect coarse differences, ideally 500–1,500 if you want to detect fine improvements&lt;/strong>, and to start by stratifying properly before obsessing over sample size.&lt;/p>
&lt;p>On top of this comes &lt;strong>active maintenance&lt;/strong>: the golden set is enriched with &lt;strong>production incidents&lt;/strong> (covered in detail in the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain post&lt;/a>), so that every real complaint that ends in a bug becomes a curated example the next deploy candidate will have to pass. The golden set is not static: it is a &lt;strong>living record of errors the system has already made and must not make again&lt;/strong>.&lt;/p>
&lt;h2 id="llm-as-judge-how-you-calibrate-an-examiner">LLM-as-judge: how you calibrate an examiner&lt;/h2>
&lt;p>Layer 3 is the one most teams misuse. The typical mistake is: &amp;ldquo;we use GPT-4 as judge because it is the most capable&amp;rdquo;. The judge is not chosen for nominal capability; it is chosen for &lt;strong>agreement with humans&lt;/strong> on the specific rubric you are measuring. A judge with 60% agreement is useless even if it is GPT-5; a Prometheus 7B fine-tuned for your domain with 88% agreement is worth more.&lt;/p>
&lt;p>The standard metric for measuring agreement between two annotators (human vs judge, or two humans with each other) is &lt;strong>Cohen&amp;rsquo;s kappa&lt;/strong>, which corrects for the agreement expected by chance:&lt;/p>
\[
\kappa = \frac{p_o - p_e}{1 - p_e}
\]
&lt;p>where \(p_o\) is the observed proportion of agreement and \(p_e\) is the proportion expected by chance under each annotator&amp;rsquo;s marginal distributions. The accepted interpretations in the literature are:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>κ &amp;lt; 0.40&lt;/strong>: poor agreement. The judge says whatever it likes.&lt;/li>
&lt;li>&lt;strong>κ ∈ [0.40, 0.60]&lt;/strong>: moderate. Acceptable for coarse signals (is it toxic?), dreadful for nuance (is it faithful to the context?).&lt;/li>
&lt;li>&lt;strong>κ ∈ [0.60, 0.80]&lt;/strong>: substantial. Useful in production for most metrics.&lt;/li>
&lt;li>&lt;strong>κ &amp;gt; 0.80&lt;/strong>: almost perfect. The judge can be treated as a substitute for the human for that specific kind of judgement.&lt;/li>
&lt;/ul>
&lt;p>Numerically, consider a binary rubric (faithful / not faithful) over 200 examples annotated by a human and by a judge. If the human said &amp;ldquo;faithful&amp;rdquo; in 150 cases and the judge in 140, and they agree on 175 of the 200, then \(p_o = 0{.}875\); the marginals are \(p_h = 0{.}75\), \(p_j = 0{.}70\), and \(p_e = 0{.}75 \cdot 0{.}70 + 0{.}25 \cdot 0{.}30 = 0{.}600\). Kappa comes out at &lt;strong>0.6875&lt;/strong>: substantial but not excellent, usable, with an eye kept on the rubric.&lt;/p>
&lt;p>Calibrating the judge means an explicit process:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Build a calibration set&lt;/strong> — 100–300 examples annotated by trained humans, with written guidelines. Inter-annotator kappa between the humans is also measured; if the humans themselves cannot agree, the rubric is badly written before you even start talking about a judge.&lt;/li>
&lt;li>&lt;strong>Iterate the judge prompt&lt;/strong> until judge agreement with the humans passes the accepted threshold (typically κ ≥ 0.7 for sensitive metrics).&lt;/li>
&lt;li>&lt;strong>Pin the judge version&lt;/strong> (&lt;code>claude-3-5-sonnet-20251022&lt;/code>, &lt;code>gpt-4o-2024-11&lt;/code>, &lt;code>prometheus-2-7b@sha256:…&lt;/code>): any change invalidates the calibration.&lt;/li>
&lt;li>&lt;strong>Recalibrate periodically&lt;/strong> — every time the judge, the judge prompt, or the rubric changes. The frequency the field recommends in 2026 is quarterly at minimum, monthly if the rubric is new.&lt;/li>
&lt;li>&lt;strong>Persist everything in lineage&lt;/strong> — a score of &amp;ldquo;faithfulness 0.87&amp;rdquo; with no traceability of which judge, which prompt, which calibration set and which human validated it, is decorative.&lt;/li>
&lt;/ol>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">prompt versioning post&lt;/a> covers how judge prompt versioning is materialised. The practical consequence is that &lt;strong>the judge is versioned like any other model&lt;/strong>: your eval suite has &lt;code>judge_id = prometheus-2.5@v3&lt;/code> just as it has &lt;code>adapter_id = customer_support_v7&lt;/code>.&lt;/p>
&lt;h2 id="the-two-cadences-ci-gate-and-platform-regression">The two cadences: CI gate and platform regression&lt;/h2>
&lt;p>Eval suites live in two places and run on two different cadences. Teams that conflate them turn one of the two into theatre.&lt;/p>
&lt;p>&lt;strong>CI gate (pre-merge, blocking).&lt;/strong> It runs on every pull request that modifies prompts, adapters, RAG configuration, or any artefact that could move the model&amp;rsquo;s output. It runs against the golden dataset versioned at the hash that is on &lt;code>main&lt;/code>. The gate fails the merge if:&lt;/p>
&lt;ul>
&lt;li>the critical metric drops &lt;strong>more than X percentage points&lt;/strong> in absolute terms against the baseline (typically X = 2);&lt;/li>
&lt;li>some safety metric (toxicity, PII leakage) crosses a hard threshold (typically tox &amp;gt; 0.02);&lt;/li>
&lt;li>some strategic segment (a language, an enterprise-type tenant) drops more than Y points even if the aggregate improves.&lt;/li>
&lt;/ul>
&lt;p>This cadence has to be &lt;strong>fast&lt;/strong> (ideally &amp;lt; 10 minutes over 500 examples) and &lt;strong>cheap&lt;/strong> (LLM judge in batch mode, cached embeddings, heuristics run locally). The CI gate is not exhaustive: it is the low-latency line of defence.&lt;/p>
&lt;p>&lt;strong>Platform regression (post-deploy, continuous).&lt;/strong> It runs on a schedule (typically nightly or weekly) over &lt;strong>sampled production traffic&lt;/strong>, not over the static golden set. It detects drift: the model has not changed, the golden set has not changed, but the users have, and quality over real traffic drops. This cadence is more expensive (judge over thousands of samples, human annotation over hundreds), tolerates latencies of hours, and its main consumer is not CI but the &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">observability&lt;/a> dashboard and the humans on the product team who decide whether to open a &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain&lt;/a> cycle.&lt;/p>
&lt;p>Both cadences persist results in the same store (Langfuse, MLflow, or equivalent) and connect them by &lt;code>model_id&lt;/code>, &lt;code>prompt_id&lt;/code>, &lt;code>dataset_hash&lt;/code> and &lt;code>judge_id&lt;/code>. Without that glue of identifiers, the metric that passes CI cannot be correlated with the one that fails in production three weeks later.&lt;/p>
&lt;h2 id="the-minimum-mathematics-that-matter">The minimum mathematics that matter&lt;/h2>
&lt;p>Beyond kappa and the interval for the difference of proportions, both already covered above, there are three more mathematical pieces that any team running evals seriously ends up using.&lt;/p>
&lt;p>&lt;strong>Confidence interval for a continuous metric.&lt;/strong> If your metric is a continuous score (faithfulness ∈ [0, 1]) and you measure the sample mean \(\bar{x}\) over n examples with standard deviation s, the 95% confidence interval for the population mean is:&lt;/p>
\[
\bar{x} \pm 1{.}96 \cdot \frac{s}{\sqrt{n}}
\]
&lt;p>For n = 300 and s ≈ 0.2 (typical of a 0-1 score with non-degenerate variance), the margin is ±0.023. This means that differences below 2 hundredths &lt;strong>cannot be distinguished&lt;/strong> from noise at that sample size. If your team is chasing &amp;ldquo;+0.5 pp&amp;rdquo; improvements over 100 examples, it is optimising noise.&lt;/p>
&lt;p>&lt;strong>Judge cost as a function of sample size and rubric.&lt;/strong> The cost of one eval pass with LLM-as-judge over n examples, with m metrics evaluated in a single call per example, and per-token price \(c_{in}, c_{out}\) on the judge model, is:&lt;/p>
\[
C \approx n \cdot (t_{in} \cdot c_{in} + t_{out} \cdot c_{out})
\]
&lt;p>where \(t_{in}\) is the number of input tokens (includes context, output of the model under test, full rubric) and \(t_{out}\) the output tokens (includes the judge&amp;rsquo;s CoT + score). For n = 500, \(t_{in}\) ≈ 4,000, \(t_{out}\) ≈ 300, a GPT-4o judge at May 2026 prices, one pass costs on the order of &lt;strong>8–15 USD per suite&lt;/strong>. If the pass fires on every PR and there are 30 PRs/day, that is 240–450 USD/day on CI gates alone. Multiplied by continuous regression, teams that do not control this spend four figures a month on judge tokens without realising. The canonical mitigation is a &lt;strong>mix of layers&lt;/strong>: heuristics and embeddings filter first, the judge is only invoked on what the cheap layers cannot resolve, and for platform regression a self-hosted open-source judge is used (Prometheus 7B on your own GPU plane) instead of a commercial model.&lt;/p>
&lt;p>&lt;strong>Distinction between aggregate metric and per-segment metric.&lt;/strong> The classic eval fallacy is the hidden mean. If your suite reports &lt;code>faithfulness = 0.87&lt;/code> and the team reads it as &amp;ldquo;up 2 points against the previous adapter&amp;rdquo;, this may be what is happening: the new adapter gains 4 points in English (where 70% of the eval set is) and loses 6 points in German (where 30% is). The aggregate mean improves, the German experience gets worse. Any serious suite reports a &lt;strong>breakdown by strategic segment&lt;/strong> (language, tenant type, question category, context length). The CI gate can also have per-segment thresholds, not just aggregate ones.&lt;/p>
&lt;h2 id="the-2026-stack-dominant-tools">The 2026 stack: dominant tools&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool&lt;/th>
&lt;th>Main layer&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Maintainer&lt;/th>
&lt;th>When to pick it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>DeepEval&lt;/strong>&lt;/td>
&lt;td>CI gate&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Confident AI&lt;/td>
&lt;td>&amp;ldquo;Evals as pytest&amp;rdquo; — assertions in Python code, trivial integration with GitHub Actions. A reasonable default.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Promptfoo&lt;/strong>&lt;/td>
&lt;td>CI gate&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Promptfoo Inc.&lt;/td>
&lt;td>Declarative YAML, matrix of prompts × providers × assertions, diff vs baseline. DevOps-friendly.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RAGAS&lt;/strong>&lt;/td>
&lt;td>RAG-specific metrics&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Exploding Gradients&lt;/td>
&lt;td>Faithfulness, context relevancy, answer relevancy. The canonical piece if your system is RAG.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Inspect AI&lt;/strong>&lt;/td>
&lt;td>Safety/capability evals&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>UK AI Safety Institute&lt;/td>
&lt;td>Suite focused on safety and capability. Useful for regulatory gates under the EU AI Act.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Langfuse Evals&lt;/strong>&lt;/td>
&lt;td>Platform regression&lt;/td>
&lt;td>MIT (OSS) / EE&lt;/td>
&lt;td>Langfuse GmbH&lt;/td>
&lt;td>Integrated with tracing — datasets, runs and scores in the same UI as production traces.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MLflow GenAI Evals&lt;/strong>&lt;/td>
&lt;td>Registry + evals&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Databricks/LF AI&lt;/td>
&lt;td>Good when you already have MLflow for classic models; &amp;ldquo;GenAI dashboard&amp;rdquo; since 3.10.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Phoenix (Arize)&lt;/strong>&lt;/td>
&lt;td>Eval + visual drift&lt;/td>
&lt;td>Elastic License 2.0&lt;/td>
&lt;td>Arize AI&lt;/td>
&lt;td>Focused on visual debugging of embeddings and drift; a complement to Langfuse, not a replacement.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Prometheus 2.5&lt;/strong>&lt;/td>
&lt;td>Self-hosted OSS judge&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>KAIST + LG AI&lt;/td>
&lt;td>Fine-tuned judge, high correlation with GPT-4 at zero cost per token when self-hosted.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>G-Eval / JudgeLM&lt;/strong>&lt;/td>
&lt;td>Prompting methods&lt;/td>
&lt;td>— (techniques)&lt;/td>
&lt;td>academic&lt;/td>
&lt;td>Prompting frameworks for LLM-as-judge — they apply on top of any judge model.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">OSS catalogue by stage&lt;/a> goes into detail entry by entry; the &lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">parallel OSS vs hyperscalers catalogue&lt;/a> compares against Bedrock Evaluations, Vertex AI Eval Service and Azure AI Evaluation.&lt;/p>
&lt;p>The canonical pattern in 2026 is hybrid: &lt;strong>DeepEval or Promptfoo for CI gates + Langfuse Evals for platform regression + Prometheus 2.5 as a self-hosted judge + human annotation on Argilla or Label Studio for the calibration set&lt;/strong>. Swapping any of these pillars for equivalents (W&amp;amp;B Weave instead of Langfuse, Inspect instead of DeepEval) is style, not functionality; what matters is that the four functions are present and connected.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For an on-premise deployment that wants to avoid sending sensitive outputs to a commercial LLM judge (for data sovereignty, ENS, NIS2 or equivalents), the judge is hosted on your own GPU plane. Reference figures for May 2026, based on &lt;strong>Prometheus 2.5 (Llama-3.1-8B fine-tuned as a judge)&lt;/strong> served on vLLM:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>RTX 4090 (24 GB, Ada Lovelace)&lt;/strong>: viable for small suites (&amp;lt; 200 examples) and for running the judge offline during development. Latency per evaluation ≈ 1.5–3 s with BF16; aggregate throughput on the order of 25–40 evaluations/min with batching. Useful for a developer&amp;rsquo;s local CI gates, not for platform regression.&lt;/li>
&lt;li>&lt;strong>Generic 4×H100 SXM configuration (320 GB total, NVLink)&lt;/strong>: runs the judge in parallel at TP=2 across two GPUs, leaving two free to serve the model under test. Aggregate throughput on the order of 200–350 evaluations/min, a full 500-example suite in 2–3 min. This allows PR gates with no perceptible waiting and nightly platform regression over thousands of samples with no per-token cost.&lt;/li>
&lt;/ul>
&lt;p>The comparative cost arithmetic is stubborn: self-hosting Prometheus 2.5 pays off an H100 in roughly 6 months &lt;strong>if the team fires ≥ 30 PRs/day with eval gates&lt;/strong>. Below that volume, the commercial judge still wins unless sovereignty is a requirement, and under ENS / NIS2 it is.&lt;/p>
&lt;h2 id="the-seven-pitfalls-that-kill-this-stage">The seven pitfalls that kill this stage&lt;/h2>
&lt;p>&lt;strong>Pitfall 1 — Aged golden dataset.&lt;/strong> It is not enriched with production incidents. After months it measures a world that no longer exists. The metric climbs comfortably while real users complain more.&lt;/p>
&lt;p>&lt;strong>Pitfall 2 — Contaminated or uncalibrated judge.&lt;/strong> The LLM judge evaluates with criteria it invents itself. Without a human calibration set as reference, there is no way to know whether its 0.89 is generous, severe or random.&lt;/p>
&lt;p>&lt;strong>Pitfall 3 — Insufficient sample size.&lt;/strong> A 50-example suite, 1-point differences the team treats as significant. The confidence interval is ±10 points. They are measuring noise and making real decisions on it.&lt;/p>
&lt;p>&lt;strong>Pitfall 4 — Runaway cost.&lt;/strong> A GPT-4 batch-mode judge fired on every PR over 1,000 examples, with no prior filtering by cheap layers. The eval bill overtakes production serving. It happens more often than people admit.&lt;/p>
&lt;p>&lt;strong>Pitfall 5 — Aggregate metric hiding segments.&lt;/strong> Global mean improves 2 points, German drops 6, enterprise tenants drop 3. With no explicit per-segment breakdown, the gate approves what it should not.&lt;/p>
&lt;p>&lt;strong>Pitfall 6 — Judge with a floating version.&lt;/strong> Judge model updated without recalibrating the rubric. Thresholds lose their statistical meaning. Last month&amp;rsquo;s regressions are not comparable with those from two months ago.&lt;/p>
&lt;p>&lt;strong>Pitfall 7 — Eval gate that is not enforced.&lt;/strong> The gate exists in the documentation but not in the real workflow: the suite takes 30 minutes, developers skip it with &lt;code>--no-verify&lt;/code>, managers ask for one-off exceptions that become the norm. An eval gate without enforcement is ornament.&lt;/p>
&lt;p>All seven are operational, not technical. The Eval layer does not break because the mathematics are wrong: it breaks because the discipline slackens. It is the same thing that happens with unit tests in any project that grows, except that here, without the discipline, the system &lt;strong>improves its metrics while getting worse&lt;/strong>, and that makes the degradation invisible until it is already ungovernable.&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>Guardrails and online safety&lt;/strong>: the &lt;strong>inline&lt;/strong> eval layer that filters outputs in real time, not in CI. Conceptually a cousin of Eval, but with very different latency constraints. Covered in detail in the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails post&lt;/a>: four lines of defence (input, retrieval, tool, output), OWASP LLM Top 10 (2025), OSS catalogue and deployment patterns.&lt;/li>
&lt;li>&lt;strong>Judge ensembles and agreement between multiple judges&lt;/strong>: how to reduce bias by combining three heterogeneous models as a panel, which aggregation function works (simple majority, trimmed mean, calibrated meta-judge).&lt;/li>
&lt;li>&lt;strong>Meta-eval&lt;/strong>: how you evaluate the suite itself. If the eval improvement from adapter v7 to v8 does not translate into a real improvement for the user, the suite is wrong, and that is also measured, by correlating eval metrics against product metrics.&lt;/li>
&lt;li>&lt;strong>Metamorphic testing&lt;/strong>: evaluating robustness against input perturbations (typos, paraphrasing, alternative language) as an extra gate. Beyond nominal agreement, it measures consistency.&lt;/li>
&lt;li>&lt;strong>Adversarial evals with red teaming&lt;/strong>: introducing prompt injection and jailbreak attacks as part of the gate.&lt;/li>
&lt;li>&lt;strong>Privacy in the judge&lt;/strong>: how to stop sensitive outputs from the model under test travelling to an external judge when regulation forbids it — homomorphic judge, judge in a TEE, or simply a self-hosted judge.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where Eval sits between Tune and Deploy. The &amp;ldquo;Stage 3 — Eval&amp;rdquo; section gives the structured summary this post develops.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of a production LLM request, May 2026&lt;/a> — the forensic tour of a request crossing the six stages; the moment the eval suite is invoked to promote adapter v7→v8 is the materialisation of the gate described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning: the contract that stops a five-word change sinking your system&lt;/a> — judge prompts are versioned with the same mechanisms as any other prompt. The &lt;code>prompt_id&lt;/code> travels in lineage.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning: DVC, lakeFS and the reproducible golden dataset challenge&lt;/a> — the golden eval set is one of the four artefacts to version differently. Without a strict holdout, the metric measures memorisation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — the eval gates that appear in that post as SQL predicates are the concrete materialisation of the framework described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop between the production incident and the adapter that fixes it&lt;/a> — the golden set is enriched with incidents coming from the Retrain loop; without that flow the dataset ages and pitfalls 1 and 5 trigger by themselves.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — entry-by-entry write-ups of DeepEval, Promptfoo, RAGAS, Langfuse, Phoenix.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">The parallel catalogue: OSS vs AWS / GCP / Azure&lt;/a> — how the Eval stage translates to Bedrock Evaluations, Vertex AI Eval Service and Azure AI Evaluation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge: the examination marker&lt;/a> — the judge piece of this mixed board taken apart in depth. G-Eval, Prometheus 2, panel of judges, the four biases (position, verbosity, self-preference, narcissism) and calibration with Cohen&amp;rsquo;s kappa.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO and SimPO&lt;/a> — the judge calibrated by the techniques here produces the preference pairs that enter as an alignment dataset. Closing the Eval → Tune loop.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the capture substrate on which continuous eval runs. The traces that tail sampling preserves (errors, high latencies, guardrail blocks) are the natural input for the judge in production.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside: v3 architecture and the 10 backend knobs&lt;/a> — the datasets and evaluators designed here rest on the Langfuse backend (ClickHouse + Postgres); that post explains how to size and tune it so it withstands the trace volume feeding the eval.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output&lt;/a> — LLM-as-judge evals benefit enormously from guaranteed structured output: a &lt;code>{score, justification, pass}&lt;/code> verdict that parses 100 % of the time removes retries and simplifies storage in the data warehouse for longitudinal analysis.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — the sibling layer at runtime. Same categories as those evaluated offline here (toxicity, PII, prompt injection, jailbreak, groundedness) but with a latency budget of 30-150 ms and compact models (PromptGuard 2, Llama Guard 4, Granite Guardian, ShieldGemma) instead of large judges.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> — the eval suite designed here is the gate &lt;strong>before&lt;/strong> the canary; unless the suite has passed offline on the candidate model, the rollout does not start.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evaluating-rag-ragas-golden-dataset-metrics/">Evaluating a RAG without fooling yourself: RAGAS, the golden dataset and the four metrics that matter&lt;/a> — the specialisation of this post&amp;rsquo;s eval framework for RAG systems; faithfulness, context precision and context recall are the eval gate metrics when the system has retrieval.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-quality-benchmarks-contamination-trap-oss-tools/">LLM quality benchmarks: MMLU, MT-Bench, HELM and the leaderboards that matter&lt;/a> — the external benchmarks that serve as a reference for calibrating whether internal evals are measuring the same thing as the field.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>G-Eval&lt;/strong>: Liu et al., &amp;ldquo;G-Eval: NLG Evaluation using GPT-4 with Better Human Alignment&amp;rdquo; — the reference paper for the chain-of-thought LLM judge method.&lt;/li>
&lt;li>&lt;strong>Prometheus&lt;/strong>: Kim et al., &amp;ldquo;Prometheus 2: An Open Source Language Model Specialized in Evaluating Other Language Models&amp;rdquo; — OSS judge with a reported 0.897 correlation vs humans.&lt;/li>
&lt;li>&lt;strong>RAGAS&lt;/strong>: Es et al., &amp;ldquo;RAGAS: Automated Evaluation of Retrieval Augmented Generation&amp;rdquo; — the paper that standardises faithfulness, context relevancy and answer relevancy.&lt;/li>
&lt;li>&lt;strong>Cohen&amp;rsquo;s kappa&lt;/strong>: Cohen, J. (1960). &amp;ldquo;A Coefficient of Agreement for Nominal Scales.&amp;rdquo; Educational and Psychological Measurement — the classic metric for inter-annotator agreement, still the operational reference.&lt;/li>
&lt;li>&lt;strong>DeepEval docs&lt;/strong>: &lt;a href="https://docs.confident-ai.com/">https://docs.confident-ai.com/&lt;/a>&lt;/li>
&lt;li>&lt;strong>Promptfoo docs&lt;/strong>: &lt;a href="https://promptfoo.dev/docs/">https://promptfoo.dev/docs/&lt;/a>&lt;/li>
&lt;li>&lt;strong>Langfuse Evals&lt;/strong>: &lt;a href="https://langfuse.com/docs/scores">https://langfuse.com/docs/scores&lt;/a>&lt;/li>
&lt;li>&lt;strong>Inspect AI&lt;/strong>: &lt;a href="https://inspect.ai-safety-institute.org.uk/">https://inspect.ai-safety-institute.org.uk/&lt;/a>&lt;/li>
&lt;li>&lt;strong>EU AI Act&lt;/strong>, relevant articles on mandatory evaluation of high-risk systems — pending publication of the CEN/CENELEC technical standards on conformity assessment for GenAI.&lt;/li>
&lt;/ul></description></item><item><title>The OSS catalogue for LLMOps in six stages: tool by tool, what each one does and when to choose it</title><link>https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/</link><pubDate>Sat, 23 May 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>For each of the six LLMOps stages (Data, Tune, Eval, Deploy, Observe, Retrain) and the two cross-cutting components (prompt + data versioning), the open source ecosystem has canonical pieces that this blog has been citing again and again. This post gathers them in one place with &lt;strong>cards of roughly 150 words per core tool&lt;/strong>: what it does, how it differs from the alternatives inside the same bucket, its &lt;strong>licence and governance model&lt;/strong>, and a typical gotcha that you only learn in production. Plus more alternatives as bullets, a &lt;strong>decision matrix per stage&lt;/strong> according to the case (small / large corpus, single tenant / multi-tenant…), a &lt;strong>diagram&lt;/strong> of the connected OSS stack and a &lt;strong>master table&lt;/strong> of licences / EE offerings. The intention: that the reader closes the post knowing what is available, which company maintains it, what gap each piece fills, and when to choose it. This is not opinion: it is a curated catalogue.&lt;/p>
&lt;h2 id="you-are-here-every-stage-but-by-oss-column">You are here: every stage, but by OSS column&lt;/h2>
&lt;p>This post shares a map with the two previous ones in the series, the six stages and the two cross-cutting pieces are all active, but it zooms in on the &lt;strong>open source column&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OSS catalogue by LLMOps pipeline stage">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:2}.cross{fill:#ffe9d6;stroke:#c66;stroke-width:1.4;rx:6}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#333}.tiny{font:10px sans-serif;fill:#444}.oss{fill:#dfe9f5;stroke:#356}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="lbl">OSS catalogue: the consultant's toolbox by stage&lt;/text>
&lt;rect x="20" y="38" width="120" height="32" class="box active"/>&lt;text x="80" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="150" y="38" width="120" height="32" class="box active"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="38" width="120" height="32" class="box active"/>&lt;text x="340" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="410" y="38" width="120" height="32" class="box active"/>&lt;text x="470" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="540" y="38" width="120" height="32" class="box active"/>&lt;text x="600" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="670" y="38" width="120" height="32" class="box active"/>&lt;text x="730" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;rect x="20" y="88" width="120" height="78" class="box oss"/>
&lt;text x="80" y="103" text-anchor="middle" class="tiny">DVC · lakeFS&lt;/text>
&lt;text x="80" y="117" text-anchor="middle" class="tiny">MinIO · Ceph&lt;/text>
&lt;text x="80" y="131" text-anchor="middle" class="tiny">Qdrant · pgvector&lt;/text>
&lt;text x="80" y="145" text-anchor="middle" class="tiny">Kafka · Flink&lt;/text>
&lt;text x="80" y="159" text-anchor="middle" class="tiny">Debezium · Karapace&lt;/text>
&lt;rect x="150" y="88" width="120" height="78" class="box oss"/>
&lt;text x="210" y="103" text-anchor="middle" class="tiny">HF Transformers&lt;/text>
&lt;text x="210" y="117" text-anchor="middle" class="tiny">PEFT · bitsandbytes&lt;/text>
&lt;text x="210" y="131" text-anchor="middle" class="tiny">DeepSpeed · FSDP&lt;/text>
&lt;text x="210" y="145" text-anchor="middle" class="tiny">Axolotl · LLaMA-Factory&lt;/text>
&lt;text x="210" y="159" text-anchor="middle" class="tiny">Ray Train · MLflow&lt;/text>
&lt;rect x="280" y="88" width="120" height="78" class="box oss"/>
&lt;text x="340" y="103" text-anchor="middle" class="tiny">DeepEval · RAGAS&lt;/text>
&lt;text x="340" y="117" text-anchor="middle" class="tiny">Promptfoo&lt;/text>
&lt;text x="340" y="131" text-anchor="middle" class="tiny">lm-eval-harness&lt;/text>
&lt;text x="340" y="145" text-anchor="middle" class="tiny">NeMo Guardrails&lt;/text>
&lt;text x="340" y="159" text-anchor="middle" class="tiny">Presidio · LlamaGuard&lt;/text>
&lt;rect x="410" y="88" width="120" height="78" class="box oss"/>
&lt;text x="470" y="103" text-anchor="middle" class="tiny">vLLM · TGI · SGLang&lt;/text>
&lt;text x="470" y="117" text-anchor="middle" class="tiny">TensorRT-LLM&lt;/text>
&lt;text x="470" y="131" text-anchor="middle" class="tiny">llama.cpp · Triton&lt;/text>
&lt;text x="470" y="145" text-anchor="middle" class="tiny">KServe · KubeRay&lt;/text>
&lt;text x="470" y="159" text-anchor="middle" class="tiny">Envoy AI · LiteLLM&lt;/text>
&lt;rect x="540" y="88" width="120" height="78" class="box oss"/>
&lt;text x="600" y="103" text-anchor="middle" class="tiny">OpenTelemetry&lt;/text>
&lt;text x="600" y="117" text-anchor="middle" class="tiny">Tempo · Jaeger&lt;/text>
&lt;text x="600" y="131" text-anchor="middle" class="tiny">Prometheus · Loki&lt;/text>
&lt;text x="600" y="145" text-anchor="middle" class="tiny">Langfuse · Phoenix&lt;/text>
&lt;text x="600" y="159" text-anchor="middle" class="tiny">Tetragon · Evidently&lt;/text>
&lt;rect x="670" y="88" width="120" height="78" class="box oss"/>
&lt;text x="730" y="103" text-anchor="middle" class="tiny">Airflow · Prefect&lt;/text>
&lt;text x="730" y="117" text-anchor="middle" class="tiny">Dagster · Argo&lt;/text>
&lt;text x="730" y="131" text-anchor="middle" class="tiny">Kubeflow Pipelines&lt;/text>
&lt;text x="730" y="145" text-anchor="middle" class="tiny">Feast&lt;/text>
&lt;text x="730" y="159" text-anchor="middle" class="tiny">Argilla · Label Studio&lt;/text>
&lt;rect x="20" y="180" width="380" height="22" class="cross"/>
&lt;text x="210" y="195" text-anchor="middle" class="sm">Prompt versioning: Langfuse Prompts · MLflow Prompt Registry&lt;/text>
&lt;rect x="410" y="180" width="380" height="22" class="cross"/>
&lt;text x="600" y="195" text-anchor="middle" class="sm">Data versioning: DVC · lakeFS · OpenLineage · DataHub&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-electricians-toolbox">The analogy: the electrician&amp;rsquo;s toolbox&lt;/h2>
&lt;p>A professional electrician arrives at an installation with a box organised into compartments. Nothing is improvised: for every type of cable there is a specific wire stripper, for every screw a screwdriver of the exact gauge, for every measurement a multimeter and a clamp meter, for every connection the right terminal block or connector. The difference between a professional electrician and a handyman is not that one knows more theory, the handyman has often read the manuals, it is that &lt;strong>the right tool is within arm&amp;rsquo;s reach and he knows when to use each one&lt;/strong>. The day the specific stripper is missing, improvising with a box cutter breaks the insulation, leaves a badly terminated cable, and the panel comes back under warranty two months later.&lt;/p>
&lt;p>The OSS LLMOps stack works the same way. For every canonical problem, versioning a dataset, indexing a corpus for retrieval, serving tokens with dynamic batching, propagating &lt;code>trace_id&lt;/code> end-to-end, managing prompts with a &lt;code>production&lt;/code> label, orchestrating retraining pipelines, there is a canonical piece of the open source ecosystem that solves it, maintained by a serious community or foundation, with a clear licence and a well documented gotcha. The consultant who knows which tool to use for each job builds a robust system in weeks; the one who improvises with &amp;ldquo;whatever the team already knows&amp;rdquo; pays later in operations, usually once the system is carrying real load and any replacement is expensive.&lt;/p>
&lt;p>This post opens the toolbox and shows each card. It is not a user manual, that is what the deep-dive posts linked at the end are for; it is the &lt;strong>curated catalogue&lt;/strong>.&lt;/p>
&lt;h2 id="diagram-of-the-connected-reference-oss-stack">Diagram of the connected reference OSS stack&lt;/h2>
&lt;p>The catalogue makes sense once you see how the pieces connect into a single coherent architecture, the one this blog has been describing throughout the series. The boxes do not float; they talk to each other over stable contracts (HTTP, gRPC, OTel, Kafka, S3/MinIO API).&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="reference OSS LLMOps stack with connected boxes">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6;fill:#eef2f7}.serv{fill:#ff8a4c;stroke:#a44}.data{fill:#dfe9f5;stroke:#356}.obs{fill:#d8eecf;stroke:#373}.ctrl{fill:#f5e3d8;stroke:#763}.bg{fill:#fafafa;stroke:#ccc;rx:8}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#222}.tiny{font:600 10px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}.otel{stroke:#1a73e8;stroke-width:1.4;fill:none;stroke-dasharray:3 2;marker-end:url(#ab)}&lt;/style>
&lt;defs>&lt;marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;marker id="ab" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#1a73e8"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="20" text-anchor="middle" class="lbl">Connected OSS LLMOps stack: serving, data, observability and control plane&lt;/text>
&lt;rect x="20" y="40" width="780" height="80" class="b bg"/>
&lt;text x="30" y="58" class="tiny">SERVING PLANE&lt;/text>
&lt;rect x="40" y="68" width="120" height="40" class="b serv"/>&lt;text x="100" y="93" text-anchor="middle" class="sm">Envoy AI Gateway&lt;/text>
&lt;rect x="180" y="68" width="120" height="40" class="b serv"/>&lt;text x="240" y="93" text-anchor="middle" class="sm">vLLM (LoRA hot-swap)&lt;/text>
&lt;rect x="320" y="68" width="120" height="40" class="b serv"/>&lt;text x="380" y="93" text-anchor="middle" class="sm">KServe / Operator&lt;/text>
&lt;rect x="460" y="68" width="120" height="40" class="b serv"/>&lt;text x="520" y="93" text-anchor="middle" class="sm">Triton (multi-model)&lt;/text>
&lt;rect x="600" y="68" width="180" height="40" class="b serv"/>&lt;text x="690" y="93" text-anchor="middle" class="sm">Kubernetes (RKE2 + Cilium)&lt;/text>
&lt;path class="arr" d="M160,88 L180,88"/>&lt;path class="arr" d="M300,88 L320,88"/>
&lt;rect x="20" y="140" width="380" height="170" class="b bg"/>
&lt;text x="30" y="158" class="tiny">DATA PLANE&lt;/text>
&lt;rect x="40" y="168" width="160" height="36" class="b data"/>&lt;text x="120" y="190" text-anchor="middle" class="sm">PostgreSQL + pgvector&lt;/text>
&lt;rect x="220" y="168" width="160" height="36" class="b data"/>&lt;text x="300" y="190" text-anchor="middle" class="sm">Qdrant (RAG)&lt;/text>
&lt;rect x="40" y="214" width="160" height="36" class="b data"/>&lt;text x="120" y="236" text-anchor="middle" class="sm">MinIO / Ceph (S3)&lt;/text>
&lt;rect x="220" y="214" width="160" height="36" class="b data"/>&lt;text x="300" y="236" text-anchor="middle" class="sm">Kafka + Debezium&lt;/text>
&lt;rect x="40" y="260" width="160" height="36" class="b data"/>&lt;text x="120" y="282" text-anchor="middle" class="sm">DVC + lakeFS&lt;/text>
&lt;rect x="220" y="260" width="160" height="36" class="b data"/>&lt;text x="300" y="282" text-anchor="middle" class="sm">Flink / Spark&lt;/text>
&lt;path class="arr" d="M380,186 L460,186 L460,88 L460,108"/>
&lt;rect x="420" y="140" width="380" height="170" class="b bg"/>
&lt;text x="430" y="158" class="tiny">OBSERVABILITY PLANE&lt;/text>
&lt;rect x="440" y="168" width="160" height="36" class="b obs"/>&lt;text x="520" y="190" text-anchor="middle" class="sm">OTel Collector&lt;/text>
&lt;rect x="620" y="168" width="160" height="36" class="b obs"/>&lt;text x="700" y="190" text-anchor="middle" class="sm">Langfuse&lt;/text>
&lt;rect x="440" y="214" width="160" height="36" class="b obs"/>&lt;text x="520" y="236" text-anchor="middle" class="sm">Tempo (traces)&lt;/text>
&lt;rect x="620" y="214" width="160" height="36" class="b obs"/>&lt;text x="700" y="236" text-anchor="middle" class="sm">Phoenix Arize OSS&lt;/text>
&lt;rect x="440" y="260" width="160" height="36" class="b obs"/>&lt;text x="520" y="282" text-anchor="middle" class="sm">Prometheus + Grafana&lt;/text>
&lt;rect x="620" y="260" width="160" height="36" class="b obs"/>&lt;text x="700" y="282" text-anchor="middle" class="sm">Loki + Tetragon&lt;/text>
&lt;path class="otel" d="M240,108 L240,130 L460,130 L460,168"/>
&lt;text x="350" y="146" class="tiny" fill="#1a73e8">OTel spans (gen_ai.*)&lt;/text>
&lt;rect x="20" y="330" width="780" height="110" class="b bg"/>
&lt;text x="30" y="348" class="tiny">CONTROL PLANE (Tune + Eval + Retrain + Prompt versioning)&lt;/text>
&lt;rect x="40" y="358" width="140" height="36" class="b ctrl"/>&lt;text x="110" y="380" text-anchor="middle" class="sm">Axolotl + PEFT&lt;/text>
&lt;rect x="200" y="358" width="140" height="36" class="b ctrl"/>&lt;text x="270" y="380" text-anchor="middle" class="sm">MLflow Tracking&lt;/text>
&lt;rect x="360" y="358" width="140" height="36" class="b ctrl"/>&lt;text x="430" y="380" text-anchor="middle" class="sm">Promptfoo + RAGAS&lt;/text>
&lt;rect x="520" y="358" width="140" height="36" class="b ctrl"/>&lt;text x="590" y="380" text-anchor="middle" class="sm">Argo / Kubeflow Pipelines&lt;/text>
&lt;rect x="680" y="358" width="100" height="36" class="b ctrl"/>&lt;text x="730" y="380" text-anchor="middle" class="sm">Argilla&lt;/text>
&lt;rect x="40" y="402" width="140" height="30" class="b ctrl"/>&lt;text x="110" y="420" text-anchor="middle" class="sm">Langfuse Prompts&lt;/text>
&lt;rect x="200" y="402" width="140" height="30" class="b ctrl"/>&lt;text x="270" y="420" text-anchor="middle" class="sm">Feast&lt;/text>
&lt;rect x="360" y="402" width="140" height="30" class="b ctrl"/>&lt;text x="430" y="420" text-anchor="middle" class="sm">NeMo Guardrails&lt;/text>
&lt;rect x="520" y="402" width="140" height="30" class="b ctrl"/>&lt;text x="590" y="420" text-anchor="middle" class="sm">OpenLineage + DataHub&lt;/text>
&lt;rect x="680" y="402" width="100" height="30" class="b ctrl"/>&lt;text x="730" y="420" text-anchor="middle" class="sm">Presidio&lt;/text>
&lt;path class="arr" d="M110,358 L110,300 L110,300"/>&lt;path class="arr" d="M110,300 L110,260 L120,260"/>
&lt;/svg>
&lt;/div>
&lt;p>Solid arrows mark data / control flow; the dashed blue ones are OTel traces. The K8s plane holds everything up. The control plane at the bottom is where the retraining pipelines, the evals in CI, the versioned prompts and the lineage live. The data plane on the left feeds both the serving side (RAG, configs) and the control plane (datasets, lineage). The observability plane receives from serving and from everything else.&lt;/p>
&lt;p>Now we go stage by stage. Each one opens with a context paragraph, then cards for the core tools (about 150 words each), bullets with relevant alternatives, and a specific decision matrix at the end.&lt;/p>
&lt;h2 id="stage-1--data--cross-cutting-data-versioning">Stage 1 — Data + cross-cutting Data versioning&lt;/h2>
&lt;p>The Data stage solves three distinct problems that beginners confuse: &lt;strong>versioning&lt;/strong> datasets (so that &lt;code>(dataset_id, version, hash)&lt;/code> exists and propagates), &lt;strong>storing and serving&lt;/strong> the operational corpus (object store + vector index + structured text), and &lt;strong>moving it&lt;/strong> between systems with CDC and stable schemas. Covered in detail in the posts on &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning with DVC and lakeFS&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in ingestion&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka&lt;/a>.&lt;/p>
&lt;h3 id="dvc-data-version-control">DVC (Data Version Control)&lt;/h3>
&lt;p>DVC puts datasets under version control with the same discipline that git applies to code. The &lt;code>.dvc&lt;/code> pointers live in git (plain text, about 200 bytes per dataset), the bulk content lives in a remote object store (S3, MinIO, Azure Blob, GCS). Each &lt;code>dvc add&lt;/code> computes a SHA-256 hash of the dataset, uploads it to the remote and stores the pointer. The key line: the &lt;code>dataset_hash&lt;/code> becomes the luggage tag that travels to the trainer, to experiment tracking and to lineage. The same dataset retrained twice produces the same hash, and therefore reproducible experiments. DVC integrates with MLflow and W&amp;amp;B as an input artefact. &lt;strong>Gotcha:&lt;/strong> it works well for datasets that change by replacement (I swap &lt;code>train.jsonl&lt;/code> for a new version) and worse for datasets with thousands of small files that change individually. For that case, combine it with lakeFS. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Iterative.ai&lt;/strong> since 2017. There is DVC Studio (managed) and &lt;code>dvc data&lt;/code> (pure CLI) on different planes.&lt;/p>
&lt;h3 id="lakefs">lakeFS&lt;/h3>
&lt;p>lakeFS brings git semantics (branch, commit, merge, rollback) to an entire S3/MinIO/ADLS bucket. Where DVC versions individual files as pointers in git, lakeFS versions &lt;strong>the whole bucket&lt;/strong>: you can branch the corpus, ingest new data into the branch, validate that it passes checks (recall@10 over golden queries for embeddings, completeness for a tabular corpus), and only then merge into &lt;code>main&lt;/code>. It is the piece that makes continuous RAG safe: the production corpus is always on &lt;code>main&lt;/code>, updates are tested on branches. It offers hooks (pre-merge, pre-commit) that fire automatic validations, and time-travel to reproduce the state of the bucket at a past date. &lt;strong>Gotcha:&lt;/strong> the manifest overhead on huge buckets (hundreds of millions of objects) deserves sizing; lakeFS keeps metadata in its own Postgres, not in the bucket. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Treeverse&lt;/strong> since 2020. Managed offering: &lt;strong>lakeFS Cloud&lt;/strong>.&lt;/p>
&lt;h3 id="minio">MinIO&lt;/h3>
&lt;p>MinIO is the S3-compatible object store that fills the &amp;ldquo;S3 on-premise&amp;rdquo; gap without surprises. API identical to S3 (the AWS SDKs work by pointing them at a different endpoint), its own CLI client (&lt;code>mc&lt;/code>), erasure-coded mode for fault tolerance, bucket-to-bucket replication, encryption at rest. It is the base on which the other data plane components are built: DVC remote, lakeFS underlying storage, Postgres snapshots, MLflow artefacts, eval datasets, saved models, distributed KV cache fabric. In small deployments it runs single-node multi-disk; in serious ones, distributed clusters. &lt;strong>Gotcha:&lt;/strong> the licence changed to &lt;strong>AGPLv3&lt;/strong> in 2021 (it was Apache 2.0 before), which means distributing software connected to MinIO obliges you to open the code that connects to it. For internal on-premise use this is not a problem; for a vendor packaging MinIO inside a commercial product, it is. Maintained by &lt;strong>MinIO Inc.&lt;/strong> with a SUBNET enterprise offering and a community fork called &lt;strong>AIStor&lt;/strong> launched in 2025.&lt;/p>
&lt;h3 id="qdrant">Qdrant&lt;/h3>
&lt;p>Qdrant is the OSS vector database most aligned with this blog&amp;rsquo;s &amp;ldquo;RAG corpus per tenant with strict ACLs&amp;rdquo; pattern. Written in Rust, it exposes a REST + gRPC API, indexes with HNSW + scalar/binary quantisation to cut memory, supports efficient payload filtering (it is not post-filtering: it integrates the filter into the HNSW search), and allows collections isolated per tenant. For the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">multi-tenant chatbot&lt;/a> scenario, Qdrant is where the &lt;code>tenant_&amp;lt;id&amp;gt;_kb_v3&lt;/code> collections with strict ACL live. It scales well horizontally (sharding by payload) and vertically (millions of chunks on a node with 64GB RAM). &lt;strong>Gotcha:&lt;/strong> binary quantisation is aggressive, it cuts VRAM by 32× but degrades recall by 10-20 %; enabling it without re-tuning the threshold breaks retrieval silently. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Qdrant Solutions GmbH&lt;/strong> (Germany). There is Qdrant Cloud (managed) and EU-only support for ENS cases.&lt;/p>
&lt;h3 id="postgresql--pgvector">PostgreSQL + pgvector&lt;/h3>
&lt;p>Postgres 18 with the &lt;code>pgvector&lt;/code> extension is the stack&amp;rsquo;s &amp;ldquo;hidden vector database&amp;rdquo;: when the corpus is small (under a million embeddings) and Postgres is already in production for operational data, running a separate Qdrant is expensive operations. pgvector adds a &lt;code>vector(dim)&lt;/code> type, HNSW and IVF indexes, and the operators &lt;code>&amp;lt;-&amp;gt;&lt;/code>, &lt;code>&amp;lt;#&amp;gt;&lt;/code>, &lt;code>&amp;lt;=&amp;gt;&lt;/code> for cosine, L2 and dot product. Combined with &lt;code>tsvector&lt;/code> (Postgres full-text search) it allows &lt;strong>hybrid search&lt;/strong>, dense + sparse, in a single SQL query. Version 0.8 (2025) introduced halfvec and bit support to reduce size by 4×-8×. &lt;strong>Gotcha:&lt;/strong> HNSW in pgvector consumes a fair amount of RAM to build the index (roughly 2× the size of the embeddings) and blocks inserts during the build; in production you build on a secondary, promote it, and discard the primary. Licence &lt;strong>PostgreSQL License&lt;/strong> (permissive BSD-style) for both the core and pgvector. Maintained by the &lt;strong>PostgreSQL Development Group&lt;/strong> plus pgvector by &lt;strong>Andrew Kane&lt;/strong> + Crunchy Data + Neon.&lt;/p>
&lt;h3 id="apache-kafka--debezium">Apache Kafka + Debezium&lt;/h3>
&lt;p>Kafka is the event bus where &amp;ldquo;everything that happens in the company is a stream&amp;rdquo; becomes real. For LLMOps in production it serves two functions: &lt;strong>CDC from source systems&lt;/strong> (Debezium captures changes in Postgres / MySQL / MongoDB and publishes them as topics) and &lt;strong>LLM event buffer&lt;/strong> (every request, every piece of feedback, every eval result ends up in a topic with the &lt;code>trace_id&lt;/code> propagated). As the &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">post on RAG over Kafka&lt;/a> explains, the RAG corpus is kept fresh by capturing changes from the CMS / source system as CDC, running the embedding in Flink streaming, and ingesting into Qdrant continuously. &lt;strong>Gotcha:&lt;/strong> a badly sized Kafka with long retention plus multi-client topics turns into a fast disk sink; measuring throughput per topic and key cardinality before production is mandatory. Kafka licence &lt;strong>Apache 2.0&lt;/strong> (&lt;strong>ASF&lt;/strong> project); Debezium &lt;strong>Apache 2.0&lt;/strong> (project incubated by &lt;strong>Red Hat&lt;/strong>). Kafka-compatible drop-in alternative: &lt;strong>Redpanda&lt;/strong> (BSL, restricted commercial use).&lt;/p>
&lt;h3 id="apache-flink-brief-mention">Apache Flink (brief mention)&lt;/h3>
&lt;p>Flink processes streams with sub-second latency and exactly-once semantics. On the LLM plane it is used to: run embeddings in streaming (over CDC topics), aggregate online metrics, materialise features for retraining. Licence Apache 2.0, ASF. Common alternative: &lt;strong>Spark Structured Streaming&lt;/strong> (also ASF, micro-batch latency).&lt;/p>
&lt;p>&lt;strong>More options for Data&lt;/strong>, mentioned on the blog:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ceph&lt;/strong> — object store for large clusters with geo-distributed replication. LGPL/Apache licence, &lt;strong>Red Hat / IBM&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Milvus&lt;/strong> — C++ vector database, an alternative to Qdrant; better for corpora of billions. Apache 2.0, &lt;strong>Zilliz&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Karapace&lt;/strong> — Confluent-compatible OSS Schema Registry. Apache 2.0, &lt;strong>Aiven&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>DataHub / Apache Atlas / OpenMetadata&lt;/strong> — catalogue + lineage. Apache 2.0, &lt;strong>Acryl Data / ASF / Collate&lt;/strong> respectively.&lt;/li>
&lt;li>&lt;strong>OpenLineage&lt;/strong> — cross-system lineage event standard. Apache 2.0, &lt;strong>Linux Foundation AI&amp;amp;Data&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Data:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Corpus &amp;lt; 1M embeddings, you already have Postgres&lt;/td>
&lt;td>&lt;strong>pgvector&lt;/strong> (one component fewer)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Corpus 1M-100M, multi-tenant with ACL&lt;/td>
&lt;td>&lt;strong>Qdrant&lt;/strong> (integrated filtering, ACLs per collection)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Corpus &amp;gt; 100M, aggressive sharding&lt;/td>
&lt;td>&lt;strong>Milvus&lt;/strong> (scales linearly better into the billions)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Training datasets + experiment tracking&lt;/td>
&lt;td>&lt;strong>DVC&lt;/strong> over MinIO + MLflow integration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RAG corpus with controlled releases&lt;/td>
&lt;td>&lt;strong>lakeFS&lt;/strong> over MinIO + pre-merge hooks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>You want both&lt;/td>
&lt;td>&lt;strong>DVC + lakeFS&lt;/strong> as complements (the blog&amp;rsquo;s recommendation)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-2--tune">Stage 2 — Tune&lt;/h2>
&lt;p>The Tune stage produces a new &lt;code>model_id, model_version&lt;/code>, typically a LoRA adapter over a stable base, with lineage back to the dataset and experiment tracking to reproduce it. Detail in the &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">post on continuous fine-tuning&lt;/a>.&lt;/p>
&lt;h3 id="huggingface-transformers--peft">HuggingFace Transformers + PEFT&lt;/h3>
&lt;p>&lt;code>transformers&lt;/code> is the canonical library for loading and training models of the decoder-only family (Llama, Mistral, Qwen, Gemma…) and encoder-decoder. &lt;code>peft&lt;/code> (Parameter-Efficient Fine-Tuning) is the complement that adds declarative support for LoRA, QLoRA, IA3 and assorted adapters. Together they form the &lt;strong>mandatory core&lt;/strong> of the OSS Tune stack: any higher-level framework (Axolotl, LLaMA-Factory) uses them underneath. PEFT allows you to train an adapter of about 280 MB (order of magnitude) instead of a full model of about 140 GB, with a functionally equivalent result on most style / domain adjustment tasks. &lt;strong>Gotcha:&lt;/strong> PEFT with a badly configured &lt;code>target_modules&lt;/code> trains an adapter that covers only Q and V of the attention, leaving out key, output proj and MLP. The result looks trained but performs poorly; adding &lt;code>target_modules=[&amp;quot;all-linear&amp;quot;]&lt;/code> fixes it (at the cost of a larger adapter). Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Hugging Face SAS&lt;/strong> (a French company); open governance model with active external maintainers.&lt;/p>
&lt;h3 id="bitsandbytes">bitsandbytes&lt;/h3>
&lt;p>bitsandbytes implements weight quantisation to 8-bit and 4-bit with NF4 for models loaded with &lt;code>transformers&lt;/code>. It reduces the 140 GB of Llama 3 70B FP16 to about 40 GB in NF4, allowing QLoRA training on a single H100 80GB. The trick is that the weights stay quantised in memory while the sensitive computations (attention, gradient updates in the adapter) are done in FP16/BF16 with on-the-fly dequantisation. Ideal for fine-tuning on limited hardware and for serving with vLLM when you want to cut VRAM. &lt;strong>Gotcha:&lt;/strong> NF4 quantisation is lossy; on small models (&amp;lt; 7B) the quality degradation is noticeable. For production serving of models under 7B, INT8 is preferred (more memory, less loss) or FP8 if the hardware supports it (the H100 does). Licence &lt;strong>MIT&lt;/strong>, maintained by &lt;strong>Tim Dettmers&lt;/strong> (originally at U. Washington, now with support from Anthropic and HuggingFace).&lt;/p>
&lt;h3 id="mlflow-tracking">MLflow Tracking&lt;/h3>
&lt;p>MLflow is the reference OSS experiment tracking tool: each trainer run records parameters (lr, batch size, epochs, target_modules), metrics (loss curves, eval scores), artefacts (model, tokeniser, configs) and, crucially, &lt;strong>input artefacts&lt;/strong> (dataset_id, dataset_hash, parent_run). The model registry associates each &lt;code>model_version&lt;/code> with a reproducible &lt;code>run_id&lt;/code>. The line of continuity between Tune and Deploy passes through here: the deployment reads the model to serve from the registry, with its lineage made explicit. MLflow 2.x integrates &lt;strong>MLflow Prompts&lt;/strong> (prompt registry) and &lt;strong>MLflow Tracing&lt;/strong> (OTel-compatible spans), cutting the number of components needed. &lt;strong>Gotcha:&lt;/strong> the default backend store is SQLite, which works for personal experiments and breaks on a shared cluster. In production: Postgres as the backend store + MinIO/S3 as the artefact store. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>LF AI &amp;amp; Data&lt;/strong> (donated by Databricks in 2020).&lt;/p>
&lt;h3 id="axolotl">Axolotl&lt;/h3>
&lt;p>Axolotl wraps &lt;code>transformers + PEFT + bitsandbytes + DeepSpeed + FSDP&lt;/code> in a declarative YAML configuration: instead of writing a script of about 300 lines to configure a fine-tuning run, you define a &lt;code>config.yml&lt;/code> with base model, dataset path, LoRA config and training hyperparameters, and run it in one line. It supports Llama, Mistral, Qwen, Gemma, Phi… loads. It keeps compatibility with the HuggingFace Hub to download models and datasets, and with MLflow / W&amp;amp;B for tracking. It is the convenience framework this blog cites when it talks about &amp;ldquo;productive fine-tuning without reinventing the wheel&amp;rdquo;. &lt;strong>Gotcha:&lt;/strong> the pace of change in the community is fast; a &lt;code>config.yml&lt;/code> that worked six months ago can break with a current version because of internal refactors. Pinning the exact Axolotl version in the environment mitigates this. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by the &lt;strong>OpenAccess AI Collective&lt;/strong> (community-driven). A very similar alternative, more widely used in China: &lt;strong>LLaMA-Factory&lt;/strong> (Apache 2.0, Beihang U.).&lt;/p>
&lt;h3 id="ray-train">Ray Train&lt;/h3>
&lt;p>Ray Train scales fine-tuning to multiple nodes by distributing workers across a Ray cluster. While DeepSpeed and FSDP are &lt;strong>intra-job parallelism&lt;/strong> (several GPUs collaborating on one job), Ray Train is the &lt;strong>orchestration plane&lt;/strong> that builds the cluster, launches workers, manages checkpoints, recovers from node failures, and integrates with Slurm or Kubernetes. For training runs beyond 8 GPUs on changing clusters, Ray Train avoids the operational burden of &amp;ldquo;manually launching N torchrun processes with NCCL&amp;rdquo;. It combines with MLflow for tracking. &lt;strong>Gotcha:&lt;/strong> the Ray learning curve is real; for a single node with 4-8 GPUs, &lt;code>torchrun&lt;/code> or Hugging Face Accelerate are simpler. Ray Train shines when there are N changing nodes. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Anyscale Inc.&lt;/strong> (commercial backer) + community. A more K8s-native alternative: &lt;strong>Kubeflow Training Operator&lt;/strong> (Apache 2.0, LF AI &amp;amp; Data).&lt;/p>
&lt;p>&lt;strong>More options for Tune:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>DeepSpeed&lt;/strong> — ZeRO parallelism in 3 stages, mixed precision, CPU/NVMe offload. MIT, &lt;strong>Microsoft&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>FSDP&lt;/strong> (Fully Sharded Data Parallel) — native PyTorch parallelism, an alternative to DeepSpeed. BSD, &lt;strong>Meta&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>LLaMA-Factory&lt;/strong> — equivalent to Axolotl with a focus on the Llama family. Apache 2.0, &lt;strong>Beihang University&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Tune:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Fine-tune on 1 GPU with 24GB (RTX 4090)&lt;/td>
&lt;td>&lt;strong>QLoRA with bitsandbytes NF4&lt;/strong> + Axolotl&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fine-tune on 1 H100 80GB, models &amp;lt; 13B&lt;/td>
&lt;td>&lt;strong>LoRA bf16&lt;/strong> + Axolotl&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fine-tune on 4-8 GPUs, single node&lt;/td>
&lt;td>&lt;strong>transformers + PEFT + Accelerate&lt;/strong> + MLflow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-node fine-tune on a K8s cluster&lt;/td>
&lt;td>&lt;strong>Kubeflow Training Operator&lt;/strong> or &lt;strong>Ray Train&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reproducible tracking is mandatory&lt;/td>
&lt;td>&lt;strong>MLflow + DVC input artefact&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>You want the minimum viable&lt;/td>
&lt;td>&lt;strong>Axolotl + MLflow&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-3--eval--guardrails">Stage 3 — Eval + Guardrails&lt;/h2>
&lt;p>Eval validates candidates pre and post promotion against a golden set with operational metrics; Guardrails runs safety online. Detailed in the posts on &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails&lt;/a>.&lt;/p>
&lt;h3 id="deepeval">DeepEval&lt;/h3>
&lt;p>DeepEval is the &amp;ldquo;pytest-style&amp;rdquo; OSS eval suite: you define tests with assertions over faithfulness, answer relevancy, contextual precision, hallucination rate, summarisation quality… and run them in CI. Each metric is an evaluator: some rule-based, others LLM-as-judge with auditable prompts. The philosophy is &amp;ldquo;evals as unit tests&amp;rdquo;: parameterisable by dataset, failable in CI, integrable with GitHub Actions. &lt;strong>Gotcha:&lt;/strong> LLM-as-judge metrics vary between judge model versions, so if the judge moves up a version, the thresholds lose their earlier statistical meaning. Explicit pinning of the judge model in config plus periodic threshold recalibration is mandatory discipline. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Confident AI&lt;/strong> (a company); parallel commercial SaaS offering. Comparable: &lt;strong>TruLens&lt;/strong> (MIT, &lt;strong>TruEra&lt;/strong>) and &lt;strong>G-Eval&lt;/strong> (academic).&lt;/p>
&lt;h3 id="ragas-rag-assessment">RAGAS (RAG Assessment)&lt;/h3>
&lt;p>RAGAS specialises in evaluating RAG pipelines. It defines four canonical metrics: &lt;strong>faithfulness&lt;/strong> (the answer is supported by the retrieved chunks), &lt;strong>answer relevancy&lt;/strong> (the answer addresses the query), &lt;strong>context precision&lt;/strong> (the retrieved chunks are relevant), &lt;strong>context recall&lt;/strong> (all the relevant chunks were retrieved). Each metric is computed with LLM-as-judge over a dataset of (query, context, expected answer). For a RAG system, RAGAS is the evaluator that measures whether retrieval is aligned with generation. It integrates with Langfuse and MLflow to store results. &lt;strong>Gotcha:&lt;/strong> RAGAS works well with golden sets of under 1000 examples; over huge golden sets the cost of the LLM judge per evaluation explodes, and the practice is to sample. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Exploding Gradients&lt;/strong> (the authors&amp;rsquo; company).&lt;/p>
&lt;h3 id="promptfoo">Promptfoo&lt;/h3>
&lt;p>Promptfoo is the declarative, CI-oriented evaluator: in &lt;code>promptfooconfig.yaml&lt;/code> you define a set of prompts and a set of assertions (contains text X, does not contain Y, faithfulness &amp;gt; 0.8, judge approves…), point at a provider (OpenAI compatible, vLLM, Ollama…), and &lt;code>promptfoo eval&lt;/code> runs the matrix of prompts × providers × assertions, returns a diff against the baseline and fails CI if anything regresses. It is the most &amp;ldquo;DevOps-friendly&amp;rdquo; piece of the evals ecosystem: it integrates trivially with GitHub Actions, GitLab CI or Jenkins. &lt;strong>Gotcha:&lt;/strong> assertion thresholds have to be calibrated with real data; starting at a default &lt;code>&amp;gt; 0.5&lt;/code> produces false positives that erode the team&amp;rsquo;s trust. Calibrate after the first week. Licence &lt;strong>MIT&lt;/strong>, maintained by &lt;strong>Promptfoo, Inc.&lt;/strong> (a company); a commercial Promptfoo Cloud SaaS offering exists but the OSS version is complete.&lt;/p>
&lt;h3 id="nemo-guardrails">NeMo Guardrails&lt;/h3>
&lt;p>NeMo Guardrails is NVIDIA&amp;rsquo;s framework for defining and enforcing policies in LLM systems through a DSL called &lt;strong>Colang&lt;/strong>. It lets you express rules such as &amp;ldquo;if the user asks about topic X, answer with template Y&amp;rdquo; or &amp;ldquo;if the model tries to do Z, block it&amp;rdquo; in a conversational-script syntax rather than in Python. It runs as middleware between the app and the model: input rails (validate what comes in), output rails (validate what goes out), dialog rails (control the flow). Designed for complex multi-turn systems where the policies are nontrivial. &lt;strong>Gotcha:&lt;/strong> Colang adds latency per turn (roughly 50-200 ms depending on the policy graph); for high-throughput conversational chat, dialog rails are disabled and only input + output remain. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>NVIDIA&lt;/strong>.&lt;/p>
&lt;h3 id="microsoft-presidio">Microsoft Presidio&lt;/h3>
&lt;p>Presidio is the most mature OSS PII (Personally Identifiable Information) detector in the ecosystem. It detects DNI, NIE, IBAN, phone numbers, emails, physical addresses, credit card numbers, personal names, dates of birth… with recognisers based on regex + NER (spaCy) + custom validators. It supports &lt;strong>redaction&lt;/strong> (replace with placeholders), &lt;strong>masking&lt;/strong> (asterisks) or &lt;strong>deterministic anonymisation&lt;/strong> (repeatable hash). For ENS/NIS2 scenarios, it is the piece placed in front (on input) and behind (on output) the LLM to guarantee that no PII is processed or emitted. &lt;strong>Gotcha:&lt;/strong> the built-in recognisers cover English well and the rest badly; for Spanish, Catalan and Basque you have to add custom recognisers, which is disciplined work but doable. Licence &lt;strong>MIT&lt;/strong>, maintained by &lt;strong>Microsoft&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>More options for Eval:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Phoenix Arize OSS&lt;/strong> — combines tracing + evals, an alternative to Langfuse Evals. ELv2, &lt;strong>Arize AI&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>lm-eval-harness&lt;/strong> — academic suite with standard benchmarks (MMLU, HellaSwag…). MIT, &lt;strong>EleutherAI&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>HELM&lt;/strong> — holistic academic evals. Apache 2.0, &lt;strong>Stanford CRFM&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Guardrails AI&lt;/strong> — a pythonic alternative to NeMo Guardrails. Apache 2.0, &lt;strong>Guardrails AI Inc.&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>LlamaGuard / PromptGuard / ShieldGemma&lt;/strong> — safety models, not frameworks. Open weights, Meta / Google.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Eval + Guardrails:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Eval in CI, &amp;ldquo;pytest for LLMs&amp;rdquo; style&lt;/td>
&lt;td>&lt;strong>Promptfoo + GitHub Actions&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Eval specific to a RAG pipeline&lt;/td>
&lt;td>&lt;strong>RAGAS + Langfuse datasets&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>General eval with custom metrics&lt;/td>
&lt;td>&lt;strong>DeepEval + MLflow dataset&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dialog policy with declarative rules&lt;/td>
&lt;td>&lt;strong>NeMo Guardrails (Colang)&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PII redaction in/out only&lt;/td>
&lt;td>&lt;strong>Presidio&lt;/strong> (you do not need NeMo)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Open safety model in Spanish&lt;/td>
&lt;td>&lt;strong>LlamaGuard 3&lt;/strong> or &lt;strong>ShieldGemma&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-4--deploy">Stage 4 — Deploy&lt;/h2>
&lt;p>Deploy serves tokens to the user with predictable throughput and latency, adapter hot-swap and multi-tenancy where it applies. Covered in the posts on &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on K8s&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM operators&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">multi-tenant cluster&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>.&lt;/p>
&lt;h3 id="vllm">vLLM&lt;/h3>
&lt;p>vLLM is &lt;strong>the&lt;/strong> reference OSS inference engine. It implements &lt;strong>PagedAttention&lt;/strong> (KV cache paging in the style of virtual memory, avoiding fragmentation), &lt;strong>continuous batching&lt;/strong> (requests join the batch as they arrive, rather than waiting for the next batch), &lt;strong>prefix caching&lt;/strong> (common prefixes, system prompts, do not recompute the KV cache), &lt;strong>LoRA hot-swap&lt;/strong> (&lt;code>--enable-lora&lt;/code> allows loading and unloading adapters without restarting the engine), an &lt;strong>OpenAI-compatible&lt;/strong> API, and &lt;strong>disaggregated prefill/decode&lt;/strong> support since 2025. It covers almost everything from Llama 3 / Mistral / Qwen / DeepSeek. &lt;strong>Gotcha:&lt;/strong> maximum throughput is only reached with &lt;code>--max-num-seqs&lt;/code> and &lt;code>--gpu-memory-utilization&lt;/code> tuned for the specific model and hardware; the defaults are conservative. The initial tuning session pays off: 2-3× throughput. Licence &lt;strong>Apache 2.0&lt;/strong>, originated at UC Berkeley, today maintained by the &lt;strong>vLLM Project / LF AI &amp;amp; Data&lt;/strong> plus a broad community (Red Hat, NVIDIA, AWS, IBM contribute). Serious alternatives in the same bucket: &lt;strong>TGI&lt;/strong> (Apache 2.0, &lt;strong>Hugging Face&lt;/strong>), &lt;strong>SGLang&lt;/strong> (Apache 2.0, &lt;strong>LMSys&lt;/strong>), &lt;strong>TensorRT-LLM&lt;/strong> (Apache 2.0, &lt;strong>NVIDIA&lt;/strong>, requires conversion).&lt;/p>
&lt;h3 id="kserve">KServe&lt;/h3>
&lt;p>KServe is the Kubernetes operator for serving ML models, LLMs included, in a declarative pattern: you define an &lt;code>InferenceService&lt;/code> YAML with the model and predictor (which can be vLLM, TGI, Triton, or a custom container) and KServe handles scheduling onto GPU nodes, autoscaling (scale-to-zero included), traffic splitting for canary, and model registry integration. It is the layer that standardises &amp;ldquo;how a model is deployed on K8s&amp;rdquo; across multiple engines, instead of inventing engine-specific YAML. It supports multi-model with &lt;strong>Inference Graphs&lt;/strong> (chaining preprocessor → model → postprocessor) and integrates with KEDA/Karpenter for GPU pool autoscaling. &lt;strong>Gotcha:&lt;/strong> scale-to-zero on GPU works badly in practice because the warm-up (loading weights into VRAM) takes tens of seconds; &lt;code>minReplicas: 1&lt;/code> is better. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Kubeflow / LF AI &amp;amp; Data&lt;/strong>. Alternatives: &lt;strong>KubeRay&lt;/strong> (Apache 2.0, &lt;strong>Anyscale&lt;/strong>), &lt;strong>llm-d&lt;/strong> (Apache 2.0, &lt;strong>CNCF&lt;/strong>), &lt;strong>KAITO&lt;/strong> (MIT, &lt;strong>Microsoft Azure&lt;/strong>).&lt;/p>
&lt;h3 id="triton-inference-server">Triton Inference Server&lt;/h3>
&lt;p>Triton serves heterogeneous models on a single backend: LLMs (via the vLLM or TensorRT-LLM backend), traditional models (ONNX, TorchScript, TensorFlow), custom models. For systems that mix LLM inference with traditional classifiers, embedding encoders, reranking models, OCR and so on, Triton avoids having N different engines in N pods. It supports ensemble models (chaining models in a single request), dynamic batching, model versioning, model warmup. &lt;strong>Gotcha:&lt;/strong> Triton is flexible but heavy to operate; for systems serving only LLMs, vLLM directly is simpler and more optimised. Triton shines when there is real heterogeneity. Licence &lt;strong>BSD-3-Clause&lt;/strong>, maintained by &lt;strong>NVIDIA&lt;/strong>.&lt;/p>
&lt;h3 id="envoy-ai-gateway">Envoy AI Gateway&lt;/h3>
&lt;p>Envoy AI Gateway is the CNCF ecosystem&amp;rsquo;s &amp;ldquo;LLM-aware API gateway&amp;rdquo;. Built on Envoy Proxy, it adds knowledge of the OpenAI-compatible APIs (chat completions, embeddings, etc.), routing across multiple backends (local vLLM + OpenAI + Anthropic + Bedrock), &lt;strong>token-based rate limiting&lt;/strong> (limits by tokens per minute, not by requests), intelligent retries, fallback between providers, and built-in OTel observability. It is the piece that makes &amp;ldquo;AI Gateway&amp;rdquo; real as an architectural category. &lt;strong>Gotcha:&lt;/strong> integration with authentication (OIDC, JWT) is flexible but requires detailed Envoy configuration; an &amp;ldquo;out of the box&amp;rdquo; AI Gateway with no configuration produces an Envoy that passes everything through. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>CNCF&lt;/strong> since the initial donation from Tetrate. Alternatives: &lt;strong>LiteLLM Proxy&lt;/strong> (MIT, &lt;strong>BerriAI&lt;/strong>), &lt;strong>Portkey&lt;/strong> (MIT, &lt;strong>Portkey AI&lt;/strong>), &lt;strong>Kong AI Gateway&lt;/strong> (Apache 2.0 base + EE, &lt;strong>Kong Inc.&lt;/strong>).&lt;/p>
&lt;h3 id="llamacpp">llama.cpp&lt;/h3>
&lt;p>llama.cpp serves LLMs on CPUs (and Apple Silicon, GPUs via Vulkan/Metal/CUDA) with very aggressive quantisation (GGUF format, down to 2-bit). It is the canonical option for inference on hardware without a dedicated GPU: edge devices, workstations, development machines. It covers everything from small models (Phi-3, Gemma 2B) to Llama 70B on hardware with enough RAM. &lt;strong>Gotcha:&lt;/strong> latency on CPU is orders of magnitude worse than on a dedicated GPU, so it is useful for offline evals, drift checks and local development, not for production serving under real load. Licence &lt;strong>MIT&lt;/strong>, maintained by &lt;strong>Georgi Gerganov&lt;/strong> + community.&lt;/p>
&lt;p>&lt;strong>More options for Deploy:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>TensorRT-LLM&lt;/strong> — maximum optimisation on NVIDIA Hopper/Ada. Apache 2.0, NVIDIA.&lt;/li>
&lt;li>&lt;strong>SGLang&lt;/strong> — good for workloads with structured generation and JSON. Apache 2.0, LMSys.&lt;/li>
&lt;li>&lt;strong>TGI&lt;/strong> — a mature alternative, focused on the HuggingFace ecosystem. Apache 2.0, HuggingFace.&lt;/li>
&lt;li>&lt;strong>NVIDIA Dynamo&lt;/strong> — multi-node disaggregated serving. Apache 2.0, NVIDIA.&lt;/li>
&lt;li>&lt;strong>llm-d&lt;/strong> — K8s operator specific to LLMs. Apache 2.0, CNCF.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Deploy:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Production serving on NVIDIA H100/A100&lt;/td>
&lt;td>&lt;strong>vLLM&lt;/strong> (the safe default)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Absolute throughput squeezing on Hopper&lt;/td>
&lt;td>&lt;strong>TensorRT-LLM&lt;/strong> + vLLM plugin or standalone&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Edge / local dev without GPU&lt;/td>
&lt;td>&lt;strong>llama.cpp&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-model (LLM + classifiers + encoders)&lt;/td>
&lt;td>&lt;strong>Triton&lt;/strong> with the vLLM backend&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Declarative K8s with autoscaling&lt;/td>
&lt;td>&lt;strong>KServe&lt;/strong> + vLLM as predictor&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AI Gateway with token rate limiting&lt;/td>
&lt;td>&lt;strong>Envoy AI Gateway&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-node disaggregated GPU cluster&lt;/td>
&lt;td>&lt;strong>NVIDIA Dynamo&lt;/strong> over vLLM&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-5--observe">Stage 5 — Observe&lt;/h2>
&lt;p>Observe propagates &lt;code>trace_id&lt;/code> end-to-end, emits runtime metrics, runs an LLM judge over sampling and detects drift. Detailed in &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">tracing with AgentSight&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability with OTel&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a>.&lt;/p>
&lt;h3 id="opentelemetry-collector">OpenTelemetry Collector&lt;/h3>
&lt;p>The OTel Collector is the agent that receives traces, metrics and logs in OTel format (or in any other via receivers), processes them (filters, sampling, attribute enrichment, PII redaction), and routes them to one or several backends (Tempo, Jaeger, Prometheus, Loki, Langfuse…). It is &lt;strong>the piece that decouples the apps from the observability backend&lt;/strong>: moving from Tempo to Jaeger means changing the Collector&amp;rsquo;s exporter, not the app. For LLMOps it matters especially because the &lt;strong>OTel GenAI semantic conventions&lt;/strong> spec defines the attributes &lt;code>gen_ai.request.model&lt;/code>, &lt;code>gen_ai.prompt.version&lt;/code>, &lt;code>gen_ai.response.tokens&lt;/code> and so on, which stitch the &lt;code>trace_id&lt;/code> to the system&amp;rsquo;s lineage. &lt;strong>Gotcha:&lt;/strong> the Collector configuration tends to grow; without discipline and periodic review it ends up as an 800-line YAML that nobody understands. Modularising with &lt;code>extensions&lt;/code> helps. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by the &lt;strong>CNCF / OpenTelemetry Project&lt;/strong>.&lt;/p>
&lt;h3 id="tempo-traces--jaeger">Tempo (traces) + Jaeger&lt;/h3>
&lt;p>Grafana Tempo is the distributed trace backend optimised for cost: it uses an object store (S3/MinIO) instead of Elasticsearch, deduplicates by &lt;code>trace_id&lt;/code>, and integrates natively with Grafana for visualisation. For LLMOps, where a real request generates 10-30 spans (gateway, prompt pull, RAG retrieval, prefill, decode N times, scoring), Tempo copes with high volumes at a reasonable cost. &lt;strong>Jaeger&lt;/strong> is the more established CNCF alternative, better for cases under 100k traces per day, worse for native object store. &lt;strong>Gotcha:&lt;/strong> Tempo has no traditional indexing; searches like &amp;ldquo;traces that took more than 5s and touched tenant X&amp;rdquo; require &lt;strong>TraceQL&lt;/strong> + Grafana and are not as fast as Jaeger with Elasticsearch. For immediate ad-hoc diagnosis it is worth keeping a parallel Jaeger with aggressive sampling. Licences &lt;strong>AGPL 3.0&lt;/strong> (Tempo) and &lt;strong>Apache 2.0&lt;/strong> (Jaeger), maintained by &lt;strong>Grafana Labs&lt;/strong> and &lt;strong>CNCF&lt;/strong> respectively.&lt;/p>
&lt;h3 id="prometheus--grafana">Prometheus + Grafana&lt;/h3>
&lt;p>Prometheus is the time-series metrics foundation of the ecosystem. Pull model (it scrapes &lt;code>/metrics&lt;/code> endpoints), PromQL for queries, exporters for everything (Postgres, Kafka, NVIDIA GPU via &lt;code>dcgm-exporter&lt;/code>, vLLM natively). Grafana visualises Prometheus + Tempo + Loki on a single plane. For LLMOps, the critical metrics are &lt;code>gpu_utilization&lt;/code>, &lt;code>kv_cache_usage_pct&lt;/code>, &lt;code>tokens_per_second&lt;/code>, &lt;code>prefill_latency_p95&lt;/code>, &lt;code>decode_latency_p95&lt;/code>, &lt;code>queue_depth&lt;/code>, aggregated by tenant. &lt;strong>Gotcha:&lt;/strong> Prometheus is very good up to about 1M active series; above that it is worth moving to &lt;strong>Thanos&lt;/strong> or &lt;strong>Mimir&lt;/strong> for long retention and horizontal scalability. For the blog&amp;rsquo;s typical LLM cluster (4-8 H100), Prometheus alone is enough. Licences &lt;strong>Apache 2.0&lt;/strong> (Prometheus, &lt;strong>CNCF&lt;/strong>) and &lt;strong>AGPL 3.0&lt;/strong> (Grafana 10+, &lt;strong>Grafana Labs&lt;/strong>).&lt;/p>
&lt;h3 id="langfuse">Langfuse&lt;/h3>
&lt;p>Langfuse is the LLM-specific OSS observability + prompt management tool. It captures spans with LLM semantic conventions (input, output, model, tokens, latency, score, user_id, session_id), visualises them as &lt;strong>conversational traces&lt;/strong> (not just span trees), manages &lt;strong>prompts versioned with a &lt;code>production&lt;/code> label&lt;/strong> and allows &lt;strong>curated datasets + evals&lt;/strong> from the same UI. For serious LLMOps, Langfuse fills the gap that neither Tempo nor Jaeger covers: a tracing UI designed to be LLM-first. &lt;strong>Gotcha:&lt;/strong> Langfuse maintains its own store (Postgres + ClickHouse for high volume); on large clusters the operational side of ClickHouse deserves attention. To get started, Postgres alone copes. Licence &lt;strong>MIT&lt;/strong> for the OSS core, &lt;strong>EE Enterprise Edition&lt;/strong> with additional features (SSO, audit logs, advanced RBAC). Maintained by &lt;strong>Langfuse GmbH&lt;/strong> (Berlin, German). There is Langfuse Cloud (SaaS).&lt;/p>
&lt;h3 id="phoenix-arize-oss">Phoenix Arize OSS&lt;/h3>
&lt;p>Phoenix is Arize AI&amp;rsquo;s OSS for LLM observability + evals, an alternative to Langfuse with a different emphasis: more oriented to evaluation and visual debugging (embedding drift, cluster analysis), less to prompt management. A good pairing with Langfuse when you want a dual approach: Langfuse for &amp;ldquo;production conversational traces&amp;rdquo;, Phoenix for &amp;ldquo;exploratory investigation of model behaviour&amp;rdquo;. &lt;strong>Gotcha:&lt;/strong> Phoenix duplicates functionality with Langfuse and with MLflow; having all three in production multiplies the operational burden. Pick one as the main tool and the others as complements. Licence &lt;strong>Elastic License 2.0&lt;/strong> (not strictly OSI), maintained by &lt;strong>Arize AI&lt;/strong>.&lt;/p>
&lt;h3 id="cilium-tetragon--hubble">Cilium Tetragon + Hubble&lt;/h3>
&lt;p>Tetragon (eBPF runtime security observer) and Hubble (eBPF network observer) are the low-level pieces that give real runtime visibility into the cluster: which processes run in which pods, what syscalls they make, what network connections they open, in real time. For ENS/NIS2 environments that demand &amp;ldquo;prove what ran in production&amp;rdquo;, Tetragon is the irrefutable audit layer: every process execution with its parent, its capabilities, its K8s context. Hubble visualises network flows by pod, namespace, service. &lt;strong>Gotcha:&lt;/strong> the volume of events generated is high; without kernel-side filtering (which Tetragon supports with &lt;code>TracingPolicy&lt;/code>), it saturates the observability plane fast. Discipline in policies. Licence &lt;strong>Apache 2.0&lt;/strong> for both, maintained by &lt;strong>Cilium / CNCF / Isovalent&lt;/strong>.&lt;/p>
&lt;h3 id="evidently-ai">Evidently AI&lt;/h3>
&lt;p>Evidently is the OSS library for &lt;strong>drift detection&lt;/strong>: it compares input and output distributions between two time windows (training vs production, current week vs previous week), applies statistical tests (KS, PSI, Wasserstein, chi-square) and generates HTML reports. For LLMOps it detects when the distribution of prompts changes (new topics, new lengths, new languages) or when the model starts answering shorter / longer / differently. &lt;strong>Gotcha:&lt;/strong> Evidently is oriented to tabular data and embeddings; for raw text it is best combined with an encoder embedder that produces vectors before applying the tests. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Evidently AI&lt;/strong> (a company). Alternatives: &lt;strong>NannyML&lt;/strong> (Apache 2.0, &lt;strong>NannyML BV&lt;/strong>), &lt;strong>Alibi Detect&lt;/strong> (Apache 2.0, &lt;strong>Seldon&lt;/strong>).&lt;/p>
&lt;p>&lt;strong>More options for Observe:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Loki&lt;/strong> — Prometheus-style logs backend for Grafana. AGPL 3.0, Grafana Labs.&lt;/li>
&lt;li>&lt;strong>Pixie&lt;/strong> — auto-instrumented eBPF observability. Apache 2.0, CNCF.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Observe:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Minimum viable stack&lt;/td>
&lt;td>&lt;strong>OTel Collector + Tempo + Prometheus + Grafana + Langfuse&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Traces with strong ad-hoc search&lt;/td>
&lt;td>Add &lt;strong>Jaeger&lt;/strong> with aggressive sampling&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ENS / NIS2 compliance runtime audit&lt;/td>
&lt;td>&lt;strong>Tetragon + Hubble&lt;/strong> + mandated retention&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Exploratory investigation of the model&lt;/td>
&lt;td>&lt;strong>Phoenix Arize OSS&lt;/strong> alongside Langfuse&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Statistical drift detection&lt;/td>
&lt;td>&lt;strong>Evidently&lt;/strong> over embeddings + inputs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cluster beyond 1M Prometheus series&lt;/td>
&lt;td>&lt;strong>Mimir&lt;/strong> (Grafana Labs) or &lt;strong>Thanos&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-6--retrain--cross-cutting-pieces">Stage 6 — Retrain + cross-cutting pieces&lt;/h2>
&lt;p>Retrain closes the loop feedback → triage → enriched dataset → new adapter. Prompt versioning and data versioning stitch lineage across stages. Detailed in &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">prompt versioning&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning&lt;/a>.&lt;/p>
&lt;h3 id="apache-airflow">Apache Airflow&lt;/h3>
&lt;p>Airflow is the most established OSS DAG scheduler. You define workflows as Python code (DAGs), each DAG with tasks (operators) that run according to declared dependencies plus a cron schedule. For retraining: a weekly DAG that extracts feedback from Postgres, triages it with an LLM-as-classifier, enriches the enriched dataset in DVC, launches the fine-tuning job on Kubernetes, runs evals against the golden set, and promotes if it passes the gates. There is a huge ecosystem of operators for everything (S3, Postgres, Kafka, Slack, K8s, Spark…). &lt;strong>Gotcha:&lt;/strong> Airflow 2.x improved a great deal over the chaos of 1.x, but the scheduler is still a component that deserves operational attention (Postgres backend, executor pool, sidecar workers); for simple pipelines it is over-engineering. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by the &lt;strong>ASF&lt;/strong>.&lt;/p>
&lt;h3 id="argo-workflows">Argo Workflows&lt;/h3>
&lt;p>Argo Workflows is the K8s-native equivalent of Airflow: each step is a container, the DAGs are defined as K8s YAML, the executor is Kubernetes itself. For environments where &lt;strong>everything is K8s&lt;/strong>, Argo fits without an extra component to maintain. Long tasks (a six-hour fine-tuning run) execute as Pods that survive control plane failures. It integrates trivially with Kubeflow Pipelines (which is built on top). &lt;strong>Gotcha:&lt;/strong> Argo&amp;rsquo;s YAML syntax is verbose; for complex DAGs, Argo feels less productive than Airflow in Python. Solutions: &lt;strong>Hera&lt;/strong> (Python DSL for Argo, a &lt;strong>DataBricks contribution&lt;/strong>) or &lt;strong>Argo + custom CRDs&lt;/strong>. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>CNCF&lt;/strong>.&lt;/p>
&lt;h3 id="kubeflow-pipelines">Kubeflow Pipelines&lt;/h3>
&lt;p>Kubeflow Pipelines is the layer above Argo Workflows oriented specifically to ML: artefact tracking, experiment tracking, reusable pipeline templates, versioned components. Built on Argo, it adds the ML conceptual model (input artefact, output artefact, metrics) that raw Argo does not have. For cyclical retraining on a K8s cluster, it is the most &amp;ldquo;ML-ready&amp;rdquo; option in the OSS ecosystem. &lt;strong>Gotcha:&lt;/strong> Kubeflow as a full suite is heavy (10+ components); many organisations install only Pipelines + Training Operator + Katib and skip Notebook Server / legacy KFServing. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>CNCF / LF AI &amp;amp; Data&lt;/strong>.&lt;/p>
&lt;h3 id="feast">Feast&lt;/h3>
&lt;p>Feast is the most widely used OSS feature store. It defines &lt;strong>feature views&lt;/strong> over batch sources (BigQuery, Postgres, Parquet) and online sources (Redis, DynamoDB, Postgres with an extension), exposes a consistent API for read-during-training and read-during-inference (point-in-time correctness), and guarantees that the features of the model in production are the same ones it was trained with. For LLMOps where the model needs consistent user / session / context features (latest plan, tenure as a customer, recent tickets), Feast provides the discipline. &lt;strong>Gotcha:&lt;/strong> for many pure LLM systems (a RAG chatbot with no complex features), Feast is over-engineering, Postgres is enough. When there are real features (recommendation, scoring, ranking), Feast shines. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>LF AI &amp;amp; Data&lt;/strong>.&lt;/p>
&lt;h3 id="argilla">Argilla&lt;/h3>
&lt;p>Argilla is the OSS annotation + HiL (human-in-the-loop) platform most aligned with modern LLMOps. It creates annotation projects with templates (classification, ranking, span annotation, RLHF preference, free-form text), connects to HuggingFace datasets, and integrates with Langfuse to import traces from production as cases to annotate. It supports multiple annotators with reconciliation, kappa scoring and quality control. To enrich retrain datasets with cases from the &amp;ldquo;blunt tone&amp;rdquo; cluster of the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain post&lt;/a>, Argilla is the front end. &lt;strong>Gotcha:&lt;/strong> Argilla requires Elasticsearch for production performance; for small experiments SQLite is enough. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Argilla, Inc.&lt;/strong> (acquired by &lt;strong>Hugging Face&lt;/strong> in 2024). Alternative: &lt;strong>Label Studio&lt;/strong> (Apache 2.0, &lt;strong>HumanSignal&lt;/strong>), more generalist, less LLM-first.&lt;/p>
&lt;h3 id="langfuse-prompts--mlflow-prompt-registry">Langfuse Prompts + MLflow Prompt Registry&lt;/h3>
&lt;p>Langfuse Prompts manages prompts as versioned entities with labels (production, staging, experiment). The client reads the active prompt from Langfuse on the request path (with a local cache of a few seconds) and propagates &lt;code>prompt_id, prompt_version&lt;/code> to the OTel span, exactly as the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">forensic post&lt;/a> does. MLflow Prompt Registry does the same with a slightly different conceptual model (no labels-as-pointers; it uses stages, like the Models registry). Both are valid; the choice depends on which tracking tool is already in place. &lt;strong>Gotcha (Langfuse):&lt;/strong> labels are mutable, so changing &lt;code>production&lt;/code> points at another version with no explicit audit; prompts are better deployed via PR against the configs repo, not manually in the UI. Licences and governance covered above.&lt;/p>
&lt;p>&lt;strong>More options for Retrain + cross-cutting pieces:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Prefect&lt;/strong> — &amp;ldquo;modern&amp;rdquo; Python DAGs, an alternative to Airflow. Apache 2.0, &lt;strong>Prefect Tech&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Dagster&lt;/strong> — DAGs with a strong focus on data assets. Apache 2.0, &lt;strong>Dagster Labs&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Label Studio&lt;/strong> — generalist annotation. Apache 2.0, &lt;strong>HumanSignal&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>OpenLineage&lt;/strong> — cross-system lineage event standard. Apache 2.0, &lt;strong>LF AI &amp;amp; Data&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>DataHub / Apache Atlas / OpenMetadata&lt;/strong> — catalogue + lineage with a UI. Apache 2.0.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Retrain + cross-cutting pieces:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Simple pipelines with a catalogue of operators&lt;/td>
&lt;td>&lt;strong>Airflow&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Everything is K8s, minimal components&lt;/td>
&lt;td>&lt;strong>Argo Workflows&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ML pipelines with artefact tracking&lt;/td>
&lt;td>&lt;strong>Kubeflow Pipelines&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HiL annotation for LLM retraining&lt;/td>
&lt;td>&lt;strong>Argilla&lt;/strong> + Langfuse integration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Features shared between training and inference&lt;/td>
&lt;td>&lt;strong>Feast&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>No complex features, only prompts + LLM&lt;/td>
&lt;td>Skip Feast&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Lightweight prompt registry&lt;/td>
&lt;td>&lt;strong>Langfuse Prompts&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MLflow is already centralised&lt;/td>
&lt;td>&lt;strong>MLflow Prompt Registry&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="master-table-licence-governance-and-enterprise-offering">Master table: licence, governance and enterprise offering&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Governance / maintainer&lt;/th>
&lt;th>Commercial EE / SaaS&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>DVC&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Iterative.ai&lt;/td>
&lt;td>DVC Studio&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>lakeFS&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Treeverse&lt;/td>
&lt;td>lakeFS Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MinIO&lt;/strong>&lt;/td>
&lt;td>AGPL v3&lt;/td>
&lt;td>MinIO Inc.&lt;/td>
&lt;td>SUBNET / AIStor&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Qdrant&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Qdrant GmbH&lt;/td>
&lt;td>Qdrant Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>pgvector&lt;/strong>&lt;/td>
&lt;td>PostgreSQL License&lt;/td>
&lt;td>Andrew Kane + community&lt;/td>
&lt;td>— (built-in Postgres clouds)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>PostgreSQL&lt;/strong>&lt;/td>
&lt;td>PostgreSQL License&lt;/td>
&lt;td>PostgreSQL Global Dev Group&lt;/td>
&lt;td>several managed (Crunchy, Neon, Aiven, EDB)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Apache Kafka&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>ASF&lt;/td>
&lt;td>Confluent Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Debezium&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Red Hat / ASF&lt;/td>
&lt;td>Debezium Server / Confluent Connectors&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Apache Flink&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>ASF&lt;/td>
&lt;td>Ververica Platform, Aiven&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>HF Transformers&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Hugging Face SAS&lt;/td>
&lt;td>HF Inference Endpoints / Enterprise Hub&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>PEFT&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Hugging Face SAS&lt;/td>
&lt;td>— (part of the HF offering)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>bitsandbytes&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Tim Dettmers + community&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MLflow&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LF AI &amp;amp; Data&lt;/td>
&lt;td>Databricks MLflow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Axolotl&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>OpenAccess AI Collective&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Ray (Train)&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Anyscale + community&lt;/td>
&lt;td>Anyscale Platform&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DeepSpeed&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Microsoft&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DeepEval&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Confident AI&lt;/td>
&lt;td>Confident AI SaaS&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RAGAS&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Exploding Gradients&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Promptfoo&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Promptfoo, Inc.&lt;/td>
&lt;td>Promptfoo Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>NeMo Guardrails&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>NVIDIA&lt;/td>
&lt;td>NeMo Microservices&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Presidio&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Microsoft&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Phoenix (Arize)&lt;/strong>&lt;/td>
&lt;td>Elastic v2&lt;/td>
&lt;td>Arize AI&lt;/td>
&lt;td>Arize Platform&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>vLLM&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>vLLM Project / LF AI &amp;amp; Data&lt;/td>
&lt;td>several (Red Hat, AWS, IBM, NVIDIA)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TGI&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Hugging Face SAS&lt;/td>
&lt;td>HF Inference Endpoints&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SGLang&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LMSys + community&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TensorRT-LLM&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>NVIDIA&lt;/td>
&lt;td>NVIDIA AI Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>llama.cpp&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Georgi Gerganov + community&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Triton Inference Server&lt;/strong>&lt;/td>
&lt;td>BSD-3&lt;/td>
&lt;td>NVIDIA&lt;/td>
&lt;td>NVIDIA AI Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>KServe&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LF AI &amp;amp; Data (Kubeflow)&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Envoy AI Gateway&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF / Tetrate&lt;/td>
&lt;td>Tetrate Service Bridge&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LiteLLM&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>BerriAI&lt;/td>
&lt;td>LiteLLM Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OpenTelemetry&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF&lt;/td>
&lt;td>several vendors (Honeycomb, Datadog, Grafana)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tempo&lt;/strong>&lt;/td>
&lt;td>AGPL 3.0&lt;/td>
&lt;td>Grafana Labs&lt;/td>
&lt;td>Grafana Cloud Tempo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Jaeger&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Prometheus&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF&lt;/td>
&lt;td>Grafana Cloud, AMP, GCP Managed Prom, Azure&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Grafana&lt;/strong>&lt;/td>
&lt;td>AGPL 3.0&lt;/td>
&lt;td>Grafana Labs&lt;/td>
&lt;td>Grafana Cloud, Grafana Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Loki&lt;/strong>&lt;/td>
&lt;td>AGPL 3.0&lt;/td>
&lt;td>Grafana Labs&lt;/td>
&lt;td>Grafana Cloud Loki&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Langfuse&lt;/strong>&lt;/td>
&lt;td>MIT (core) / EE&lt;/td>
&lt;td>Langfuse GmbH&lt;/td>
&lt;td>Langfuse Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tetragon&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Cilium / CNCF / Isovalent&lt;/td>
&lt;td>Isovalent Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Hubble&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Cilium / CNCF&lt;/td>
&lt;td>Isovalent Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Evidently AI&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Evidently AI&lt;/td>
&lt;td>Evidently Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Apache Airflow&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>ASF&lt;/td>
&lt;td>Astronomer, MWAA, Cloud Composer&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Argo Workflows&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Kubeflow Pipelines&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF / LF AI &amp;amp; Data&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Feast&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LF AI &amp;amp; Data&lt;/td>
&lt;td>Tecton (commercial)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Argilla&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Hugging Face&lt;/td>
&lt;td>HF Hub features&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OpenLineage&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LF AI &amp;amp; Data&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DataHub&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Acryl Data&lt;/td>
&lt;td>Acryl Cloud&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>The pattern to watch for when reading the table:&lt;/strong> &lt;strong>AGPL 3.0&lt;/strong> and &lt;strong>Elastic v2&lt;/strong> are the ones that add the most friction in companies with strict licensing policies (legal asks for a specific review). The &lt;strong>Apache 2.0&lt;/strong> ones pass compliance without argument. Those with an &amp;ldquo;EE Enterprise&amp;rdquo; tier or equivalent hide a decision: the OSS version is functionally complete for production, but team features (SSO, audit, advanced RBAC) live in the commercial version. For ENS customers under a HIGH declaration, the EE features (SSO with corporate SAML/OIDC, immutable audit logs) are usually mandatory, so it is worth knowing the price beforehand.&lt;/p>
&lt;h2 id="when-to-move-from-the-minimum-stack-to-the-full-stack">When to move from the &amp;ldquo;minimum stack&amp;rdquo; to the &amp;ldquo;full stack&amp;rdquo;&lt;/h2>
&lt;p>The whole catalogue can be intimidating. But you do not build all of it on day one. There is a reasonable order that this blog has been validating in posts throughout the series. The &lt;strong>minimum viable stack&lt;/strong> that serves an LLM API with acceptable discipline:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Serving&lt;/strong>: vLLM on Kubernetes plus an Envoy AI Gateway in front.&lt;/li>
&lt;li>&lt;strong>Data&lt;/strong>: Postgres + pgvector (no Qdrant), MinIO for object store, no Kafka.&lt;/li>
&lt;li>&lt;strong>Tune&lt;/strong>: Axolotl + MLflow, no Ray Train.&lt;/li>
&lt;li>&lt;strong>Eval&lt;/strong>: Promptfoo in CI, no RAGAS and no judge in production.&lt;/li>
&lt;li>&lt;strong>Observe&lt;/strong>: OTel Collector + Prometheus + Grafana + Langfuse, no Phoenix and no Tetragon.&lt;/li>
&lt;li>&lt;strong>Retrain&lt;/strong>: feedback in Postgres + crontab scripts, no Airflow.&lt;/li>
&lt;li>&lt;strong>Versioning&lt;/strong>: prompts in Langfuse + datasets in DVC over MinIO, no lakeFS.&lt;/li>
&lt;/ul>
&lt;p>That is &lt;strong>about 8-10 components&lt;/strong> and it serves a reasonable LLM system for a single tenant with moderate traffic. As the system grows, there are identifiable moments where adding each piece pays off:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Trigger&lt;/th>
&lt;th>Component to add&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Multi-tenant with isolated corpora&lt;/td>
&lt;td>&lt;strong>Qdrant&lt;/strong> (collections per tenant, ACL)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The corpus is refreshed often and periodically breaks&lt;/td>
&lt;td>&lt;strong>lakeFS&lt;/strong> (branches with pre-merge hooks)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The embedding pipeline needs streaming&lt;/td>
&lt;td>&lt;strong>Kafka + Debezium + Flink&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Retraining moves from monthly to weekly&lt;/td>
&lt;td>&lt;strong>Airflow&lt;/strong> or &lt;strong>Argo Workflows&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Shared features appear (customer profile, scoring)&lt;/td>
&lt;td>&lt;strong>Feast&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Annotation outgrows informal capacity&lt;/td>
&lt;td>&lt;strong>Argilla&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RAG eval needs specific metrics&lt;/td>
&lt;td>&lt;strong>RAGAS&lt;/strong> + Langfuse datasets&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ENS compliance demands runtime audit&lt;/td>
&lt;td>&lt;strong>Tetragon + Hubble&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Drift is invisible and shows up late&lt;/td>
&lt;td>&lt;strong>Evidently&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A single stack stops covering multi-model&lt;/td>
&lt;td>&lt;strong>Triton&lt;/strong> or &lt;strong>KServe&lt;/strong> with several predictors&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multiple simultaneous multi-tenant adapters&lt;/td>
&lt;td>&lt;strong>vLLM Production Stack&lt;/strong> + a dedicated Operator&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Each jump adds 1-2 components and is worth the cost only when the trigger is clear. Adding Kafka &amp;ldquo;just in case&amp;rdquo; when the corpus is updated once a month is net negative work.&lt;/p>
&lt;h2 id="what-we-have-not-covered-yet">What we have not covered (yet)&lt;/h2>
&lt;p>Some pieces still deserve a post of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Schema Registry&lt;/strong> for LLM data and prompts (Confluent OSS, Karapace, JSON Schema Registry).&lt;/li>
&lt;li>&lt;strong>Catalogue + lineage&lt;/strong> in depth: DataHub vs Atlas vs OpenMetadata + OpenLineage taken seriously.&lt;/li>
&lt;li>&lt;strong>Federated learning&lt;/strong> over OSS (Flower, FedML) for scenarios where the data is not centralised.&lt;/li>
&lt;li>&lt;strong>OSS MCP Servers&lt;/strong> and their place in the stack as a tools / actions layer.&lt;/li>
&lt;li>&lt;strong>&amp;ldquo;Agentic&amp;rdquo; evals&lt;/strong> specific to multi-step systems with tool use.&lt;/li>
&lt;li>&lt;strong>Upgrade best practices&lt;/strong> for each component (vLLM every 6 weeks, Kafka major every 18 months, etc.).&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/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the forensic piece that follows one request through the six stages; this catalogue is the list of tools that appeared along that route.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">The parallel catalogue: OSS vs hyperscalers&lt;/a> — the horizontal cut that shows, for each stage, what each OSS tool does and what its equivalent is on AWS, GCP and Azure.&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 of the pipeline to which this catalogue puts concrete OSS names.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps specific to LLMs in 2026&lt;/a> — general context on LLMOps.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning with DVC and lakeFS&lt;/a> — the deep-dive on the two OSS protagonists of the Data stage + cross-cutting piece.&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 deep-dive on the Prompt cross-cutting piece.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — the Tune stage in real operations.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — the deep-dives on Eval + safety.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard: the sworn translator with a notebook of equivalences&lt;/a> — a specific zoom on the Protect AI tool with its Anonymize + Vault + Deanonymize pattern, the 36 composable scanners and the four deployment modes (lib, FastAPI API, OTel sidecar, AI Gateway plugin).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on K8s&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s operators&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">Multi-tenant GPU cluster&lt;/a> — Deploy in all its layers.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight LLM tracing&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability with OTel&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a> — Observe from its three angles.&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 loop&lt;/a> — the Retrain stage in detail.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside: v3 architecture and the 10 backend knobs&lt;/a> — the operational deep-dive on the star observability tool of this catalogue: a six-service architecture, asynchronous ingestion and self-hosted tuning on the example cluster.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents: overview&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">runbook&lt;/a> — the runtime security layer for agents that execute code: bubblewrap/ai-jail on the client and Tetragon (eBPF) on the cluster, with its Kata &lt;code>RuntimeClass&lt;/code>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">Model chain of trust (1/4): KServe and the Open Inference Protocol&lt;/a> — the deep-dive on the control plane of the Deploy stage: InferenceService, ServingRuntime and the API contract that this catalogue only enumerates.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Model chain of trust (2/4): registry, OCI artefacts and distribution&lt;/a> — the missing piece between Tune and Deploy: where the weights live (OCI artefacts with ORAS, Harbor, MLflow) and how they reach the GPU node.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://docs.vllm.ai/">vLLM&lt;/a> · &lt;a href="https://github.com/vllm-project/production-stack">vLLM Production Stack&lt;/a> · &lt;a href="https://huggingface.co/docs/text-generation-inference/">TGI&lt;/a> · &lt;a href="https://docs.sglang.ai/">SGLang&lt;/a> · &lt;a href="https://nvidia.github.io/TensorRT-LLM/">TensorRT-LLM&lt;/a> · &lt;a href="https://github.com/ggerganov/llama.cpp">llama.cpp&lt;/a> — OSS inference engines.&lt;/li>
&lt;li>&lt;a href="https://docs.nvidia.com/deeplearning/triton-inference-server/">Triton Inference Server&lt;/a> · &lt;a href="https://kserve.github.io/website/">KServe&lt;/a> · &lt;a href="https://aigateway.envoyproxy.io/">Envoy AI Gateway&lt;/a> · &lt;a href="https://docs.litellm.ai/">LiteLLM&lt;/a> — orchestration and AI gateway.&lt;/li>
&lt;li>&lt;a href="https://qdrant.tech/documentation/">Qdrant&lt;/a> · &lt;a href="https://github.com/pgvector/pgvector">pgvector&lt;/a> · &lt;a href="https://milvus.io/docs">Milvus&lt;/a> — vector databases.&lt;/li>
&lt;li>&lt;a href="https://dvc.org/doc">DVC&lt;/a> · &lt;a href="https://docs.lakefs.io/">lakeFS&lt;/a> · &lt;a href="https://min.io/docs/">MinIO&lt;/a> — versioning and object store.&lt;/li>
&lt;li>&lt;a href="https://kafka.apache.org/documentation/">Apache Kafka&lt;/a> · &lt;a href="https://debezium.io/documentation/">Debezium&lt;/a> · &lt;a href="https://flink.apache.org/">Apache Flink&lt;/a> — streams and CDC.&lt;/li>
&lt;li>&lt;a href="https://huggingface.co/docs/transformers">Hugging Face Transformers&lt;/a> · &lt;a href="https://huggingface.co/docs/peft">PEFT&lt;/a> · &lt;a href="https://huggingface.co/docs/bitsandbytes">bitsandbytes&lt;/a> · &lt;a href="https://docs.axolotl.ai/">Axolotl&lt;/a> — fine-tuning.&lt;/li>
&lt;li>&lt;a href="https://mlflow.org/docs/">MLflow&lt;/a> · &lt;a href="https://docs.ray.io/en/latest/train/">Ray Train&lt;/a> · &lt;a href="https://www.kubeflow.org/docs/components/trainer/">Kubeflow Training Operator&lt;/a> — training orchestration.&lt;/li>
&lt;li>&lt;a href="https://docs.confident-ai.com/">DeepEval&lt;/a> · &lt;a href="https://docs.ragas.io/">RAGAS&lt;/a> · &lt;a href="https://promptfoo.dev/docs/">Promptfoo&lt;/a> · &lt;a href="https://docs.nvidia.com/nemo/guardrails/">NeMo Guardrails&lt;/a> · &lt;a href="https://microsoft.github.io/presidio/">Presidio&lt;/a> — evals and guardrails.&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/docs/">OpenTelemetry&lt;/a> · &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">OTel GenAI semconv&lt;/a> · &lt;a href="https://grafana.com/docs/tempo/">Tempo&lt;/a> · &lt;a href="https://prometheus.io/docs/">Prometheus&lt;/a> · &lt;a href="https://grafana.com/docs/">Grafana&lt;/a> · &lt;a href="https://grafana.com/docs/loki/">Loki&lt;/a> — observability foundation.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs">Langfuse&lt;/a> · &lt;a href="https://docs.arize.com/phoenix">Phoenix Arize&lt;/a> · &lt;a href="https://docs.evidentlyai.com/">Evidently AI&lt;/a> — LLM observability and drift.&lt;/li>
&lt;li>&lt;a href="https://docs.cilium.io/">Cilium&lt;/a> · &lt;a href="https://tetragon.io/">Tetragon&lt;/a> · &lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">Hubble&lt;/a> — eBPF runtime.&lt;/li>
&lt;li>&lt;a href="https://airflow.apache.org/docs/">Apache Airflow&lt;/a> · &lt;a href="https://argo-workflows.readthedocs.io/">Argo Workflows&lt;/a> · &lt;a href="https://www.kubeflow.org/docs/components/pipelines/">Kubeflow Pipelines&lt;/a> · &lt;a href="https://docs.feast.dev/">Feast&lt;/a> · &lt;a href="https://docs.argilla.io/">Argilla&lt;/a> — orchestration + retrain + annotation.&lt;/li>
&lt;/ul></description></item><item><title>The parallel catalogue: the six LLMOps stages in open source and on the hyperscalers (AWS, GCP, Azure)</title><link>https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/</link><pubDate>Sat, 23 May 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">previous forensic post&lt;/a> used a single request to walk the six stages of the LLMOps pipeline and the two cross-cutting components. This post walks the same stages but crosses them with three extra columns: how each stage is built in &lt;strong>open source on-premise&lt;/strong>, and what the equivalent services are on &lt;strong>AWS&lt;/strong>, &lt;strong>GCP&lt;/strong> and &lt;strong>Azure&lt;/strong>. It is not a migration guide nor a cost benchmark: it is a &lt;strong>catalogue of equivalences&lt;/strong> with its gaps. The general pattern you will see: OSS gives you control, sovereignty and free composition in exchange for expensive operations; the hyperscalers give you integration and time-to-market in exchange for lock-in on margins, data contracts and political dependency. For scenarios subject to ENS / NIS2 with critical customer data, OSS wins by default; for discovery projects where time-to-market is the deciding metric, the hyperscaler wins by default. The interesting part is in the middle. As a concrete thread, at the end we take the multi-tenant chatbot from the previous post and port it to AWS piece by piece to show what disappears, what appears, and where the lock-in materialises.&lt;/p>
&lt;h2 id="you-are-here-the-same-six-stages-but-by-column">You are here: the same six stages, but by column&lt;/h2>
&lt;p>This post shares a map with the previous one, the six stages and the two cross-cutting pieces are all active, but it changes the cut: instead of following a request horizontally, it makes the vertical cut and shows which tools live in each stage depending on the deployment model.&lt;/p>
&lt;div class="diagram" style="max-width:840px;margin:1rem auto;">
&lt;svg viewBox="0 0 840 310" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="the six LLMOps stages with their OSS and hyperscaler equivalents">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:2}.cross{fill:#ffe9d6;stroke:#c66;stroke-width:1.4;rx:6}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:10px sans-serif;fill:#333}.tiny{font:9px sans-serif;fill:#222}.row{font:600 10px sans-serif;fill:#333}.oss{fill:#dfe9f5;stroke:#356}.aws{fill:#fde6c8;stroke:#a65}.gcp{fill:#dceaf8;stroke:#369}.azu{fill:#dde2f8;stroke:#447}.foot{font:9px sans-serif;fill:#666}&lt;/style>
&lt;text x="420" y="20" text-anchor="middle" class="lbl">Parallel catalogue: open source on-premise vs managed hyperscalers&lt;/text>
&lt;rect x="60" y="35" width="125" height="30" class="box active"/>&lt;text x="122.5" y="54" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="190" y="35" width="125" height="30" class="box active"/>&lt;text x="252.5" y="54" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="320" y="35" width="125" height="30" class="box active"/>&lt;text x="382.5" y="54" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="450" y="35" width="125" height="30" class="box active"/>&lt;text x="512.5" y="54" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="580" y="35" width="125" height="30" class="box active"/>&lt;text x="642.5" y="54" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="710" y="35" width="125" height="30" class="box active"/>&lt;text x="772.5" y="54" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;text x="52" y="98" text-anchor="end" class="row">OSS&lt;/text>
&lt;rect x="60" y="80" width="125" height="30" class="box oss"/>&lt;text x="122.5" y="99" text-anchor="middle" class="tiny">DVC · lakeFS · Qdrant&lt;/text>
&lt;rect x="190" y="80" width="125" height="30" class="box oss"/>&lt;text x="252.5" y="99" text-anchor="middle" class="tiny">PEFT · MLflow · Ray&lt;/text>
&lt;rect x="320" y="80" width="125" height="30" class="box oss"/>&lt;text x="382.5" y="99" text-anchor="middle" class="tiny">DeepEval · Promptfoo&lt;/text>
&lt;rect x="450" y="80" width="125" height="30" class="box oss"/>&lt;text x="512.5" y="99" text-anchor="middle" class="tiny">vLLM · KServe · Operators&lt;/text>
&lt;rect x="580" y="80" width="125" height="30" class="box oss"/>&lt;text x="642.5" y="99" text-anchor="middle" class="tiny">OTel · Tempo · Langfuse&lt;/text>
&lt;rect x="710" y="80" width="125" height="30" class="box oss"/>&lt;text x="772.5" y="99" text-anchor="middle" class="tiny">Airflow · Argo · Kubeflow&lt;/text>
&lt;text x="52" y="138" text-anchor="end" class="row">AWS&lt;/text>
&lt;rect x="60" y="120" width="125" height="30" class="box aws"/>&lt;text x="122.5" y="139" text-anchor="middle" class="tiny">S3 · OpenSearch · MSK&lt;/text>
&lt;rect x="190" y="120" width="125" height="30" class="box aws"/>&lt;text x="252.5" y="139" text-anchor="middle" class="tiny">SageMaker · Bedrock&lt;/text>
&lt;rect x="320" y="120" width="125" height="30" class="box aws"/>&lt;text x="382.5" y="139" text-anchor="middle" class="tiny">Bedrock Eval · Guardrails&lt;/text>
&lt;rect x="450" y="120" width="125" height="30" class="box aws"/>&lt;text x="512.5" y="139" text-anchor="middle" class="tiny">Bedrock · SM Endpoints&lt;/text>
&lt;rect x="580" y="120" width="125" height="30" class="box aws"/>&lt;text x="642.5" y="139" text-anchor="middle" class="tiny">CloudWatch · X-Ray · ADOT&lt;/text>
&lt;rect x="710" y="120" width="125" height="30" class="box aws"/>&lt;text x="772.5" y="139" text-anchor="middle" class="tiny">SM Pipelines · GT&lt;/text>
&lt;text x="52" y="178" text-anchor="end" class="row">GCP&lt;/text>
&lt;rect x="60" y="160" width="125" height="30" class="box gcp"/>&lt;text x="122.5" y="179" text-anchor="middle" class="tiny">GCS · BQ · Vertex VS&lt;/text>
&lt;rect x="190" y="160" width="125" height="30" class="box gcp"/>&lt;text x="252.5" y="179" text-anchor="middle" class="tiny">Vertex Training · Tuning&lt;/text>
&lt;rect x="320" y="160" width="125" height="30" class="box gcp"/>&lt;text x="382.5" y="179" text-anchor="middle" class="tiny">Vertex Eval · Model Armor&lt;/text>
&lt;rect x="450" y="160" width="125" height="30" class="box gcp"/>&lt;text x="512.5" y="179" text-anchor="middle" class="tiny">Vertex Pred · Gemini API&lt;/text>
&lt;rect x="580" y="160" width="125" height="30" class="box gcp"/>&lt;text x="642.5" y="179" text-anchor="middle" class="tiny">Cloud Trace · Monitoring&lt;/text>
&lt;rect x="710" y="160" width="125" height="30" class="box gcp"/>&lt;text x="772.5" y="179" text-anchor="middle" class="tiny">Vertex Pipelines&lt;/text>
&lt;text x="52" y="218" text-anchor="end" class="row">Azure&lt;/text>
&lt;rect x="60" y="200" width="125" height="30" class="box azu"/>&lt;text x="122.5" y="219" text-anchor="middle" class="tiny">ADLS · AI Search · ADF&lt;/text>
&lt;rect x="190" y="200" width="125" height="30" class="box azu"/>&lt;text x="252.5" y="219" text-anchor="middle" class="tiny">Azure ML · AOAI tuning&lt;/text>
&lt;rect x="320" y="200" width="125" height="30" class="box azu"/>&lt;text x="382.5" y="219" text-anchor="middle" class="tiny">AI Eval · Content Safety&lt;/text>
&lt;rect x="450" y="200" width="125" height="30" class="box azu"/>&lt;text x="512.5" y="219" text-anchor="middle" class="tiny">AOAI · ML Endpoints&lt;/text>
&lt;rect x="580" y="200" width="125" height="30" class="box azu"/>&lt;text x="642.5" y="219" text-anchor="middle" class="tiny">App Insights · Monitor&lt;/text>
&lt;rect x="710" y="200" width="125" height="30" class="box azu"/>&lt;text x="772.5" y="219" text-anchor="middle" class="tiny">Azure ML Pipelines&lt;/text>
&lt;rect x="60" y="245" width="385" height="26" class="cross"/>
&lt;text x="252.5" y="262" text-anchor="middle" class="sm">Prompt versioning: Langfuse · MLflow ↔ Bedrock · Vertex · Foundry&lt;/text>
&lt;rect x="450" y="245" width="385" height="26" class="cross"/>
&lt;text x="642.5" y="262" text-anchor="middle" class="sm">Data versioning: DVC · lakeFS · OpenLineage ↔ S3 · Dataplex · Purview&lt;/text>
&lt;text x="420" y="295" text-anchor="middle" class="foot">The OSS stack runs on-premise; AWS / GCP / Azure show the managed equivalents per stage.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-your-own-bakery-and-the-franchise">The analogy: your own bakery and the franchise&lt;/h2>
&lt;p>A baker opens a business. There are two possible models.&lt;/p>
&lt;p>He can open &lt;strong>his own bakery&lt;/strong>: rent the premises, buy the oven, choose the flour suppliers, hire his master baker, write his recipes, set the prices, dress the shop window. The day he wants to launch an organic rye sourdough loaf, he asks nobody for permission. The day the price of flour goes up, he finds another supplier. But he pays for all of it: the initial investment, the risk, the daily operations, the months when he misreads the neighbourhood. The bakery is his.&lt;/p>
&lt;p>Or he can join a &lt;strong>franchise&lt;/strong>: the franchisor hands over the turnkey premises, the oven with a maintenance contract, the suppliers already negotiated, the operating manuals, the written recipes, the centralised marketing, the loyalty app, the POS system. The learning curve is weeks, not years. But the recipes belong to the franchisor, so do the suppliers, the price of bread is in the catalogue, and the day the croissant formula changes he gets an informational email, not a business decision.&lt;/p>
&lt;p>Both bakeries turn out bread. Both meet health regulations and generate income. The operational difference is enormous and it is not about technology: it is about &lt;strong>ownership, control and timescale&lt;/strong>.&lt;/p>
&lt;p>The parallel with LLMOps is direct. The on-premise OSS stack is the bakery you own. The managed stack on the hyperscalers is the franchise. The &lt;strong>pieces&lt;/strong> that appear in each stage are functionally equivalent, at the end of the day both solve the same technical problem, but the governance model, the operational cost, the lock-in and the compliance guarantees are different. This post builds the parallel catalogue so that the choice is not made by default.&lt;/p>
&lt;h2 id="quick-recap-of-the-previous-post">Quick recap of the previous post&lt;/h2>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">forensic post&lt;/a> we followed one specific request: a premium-es user of an insurance company asking &lt;em>&amp;ldquo;How do I cancel my premium subscription?&amp;rdquo;&lt;/em> to the multi-tenant support chatbot of the SaaS provider that hosts it. The route crossed the &lt;strong>six stages of the LLMOps pipeline&lt;/strong>, Data, Tune, Eval, Deploy, Observe, Retrain, plus the &lt;strong>two cross-cutting components&lt;/strong>, prompt versioning and data versioning, over an on-premise infrastructure: RKE2 with Cilium BGP, a 4×H100 SXM cluster, an RTX 4090 for development, vLLM on Kubernetes, Langfuse + OTel + Prometheus + Tempo, Postgres + Qdrant, DVC + lakeFS + MinIO, Kafka and MLflow. The system meets ENS / NIS2 and keeps &lt;code>trace_id&lt;/code> propagated end to end.&lt;/p>
&lt;p>What comes now is that same system, piece by piece, showing for each box which tool does the job if you are on public cloud, because the integrator&amp;rsquo;s question is rarely &amp;ldquo;OSS yes or no?&amp;rdquo;: it is &amp;ldquo;what do I lose and what do I gain if I take this box managed?&amp;rdquo;. And the answer differs box by box.&lt;/p>
&lt;h2 id="stage-1--data">Stage 1 — Data&lt;/h2>
&lt;p>&lt;strong>The problem.&lt;/strong> There are three sub-problems the Data stage solves, frequently confused with one another. First, &lt;strong>versioning and identity&lt;/strong> of the corpus and of the training datasets (making sure a &lt;code>dataset_id, dataset_version&lt;/code> exists and propagates). Second, &lt;strong>storage and serving&lt;/strong> of the operational corpus (object store + vector index + structured text). Third, &lt;strong>streams and ingestion&lt;/strong> from source systems with CDC, transformation and stable schemas (Schema Registry).&lt;/p>
&lt;p>&lt;strong>Reference OSS stack.&lt;/strong> Versioning lives in &lt;strong>DVC&lt;/strong> (pointers in git, content in an object store) combined with &lt;strong>lakeFS&lt;/strong> for branch/merge semantics over data. The &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">post on data versioning&lt;/a> goes into the functional difference. The object store is &lt;strong>MinIO&lt;/strong> or &lt;strong>Ceph&lt;/strong>. The vector index is &lt;strong>Qdrant&lt;/strong> or &lt;strong>Milvus&lt;/strong> for large corpora (millions of chunks) and &lt;strong>pgvector on Postgres 18&lt;/strong> for small cases where having one component fewer pays off operationally. The stream layer is &lt;strong>Kafka&lt;/strong> (pure Apache or &lt;strong>Redpanda&lt;/strong>) with a &lt;strong>Schema Registry&lt;/strong> (Confluent or &lt;strong>Karapace&lt;/strong> OSS), CDC with &lt;strong>Debezium&lt;/strong> or &lt;strong>Flink CDC&lt;/strong>, transformation with &lt;strong>Flink&lt;/strong> or &lt;strong>Spark Structured Streaming&lt;/strong>. The catalogue / lineage is &lt;strong>DataHub&lt;/strong>, &lt;strong>Apache Atlas&lt;/strong> or &lt;strong>OpenMetadata&lt;/strong> with &lt;strong>OpenLineage&lt;/strong> events between systems. The &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">post on PostgreSQL + Qdrant ingestion&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">post on RAG over Kafka&lt;/a> cover the detailed operations.&lt;/p>
&lt;p>&lt;strong>Hyperscaler equivalents.&lt;/strong> On &lt;strong>AWS&lt;/strong>, the corpus lives in &lt;strong>S3&lt;/strong> (with versioning enabled, which is the cheap substitute for serious data versioning), tabular queries in &lt;strong>Athena&lt;/strong> or &lt;strong>Redshift&lt;/strong>, the vector index in &lt;strong>Amazon OpenSearch&lt;/strong> with the vector plug-in or in &lt;strong>Amazon Aurora pgvector&lt;/strong>. The stream layer is &lt;strong>MSK&lt;/strong> (managed Kafka) or &lt;strong>Kinesis Data Streams&lt;/strong>, CDC with &lt;strong>AWS DMS&lt;/strong>, transformation with &lt;strong>Glue Streaming&lt;/strong> or &lt;strong>MSK Connect&lt;/strong>. The catalogue is &lt;strong>AWS Glue Data Catalog&lt;/strong> + &lt;strong>AWS Lake Formation&lt;/strong> for data governance. And for the RAG case there is also &lt;strong>Amazon Bedrock Knowledge Bases&lt;/strong>, the managed shortcut: you give it S3, it indexes into OpenSearch or Aurora pgvector, exposes a retrieval API and the operations end there, in exchange for paying per indexed chunk and per query.&lt;/p>
&lt;p>On &lt;strong>GCP&lt;/strong>, the corpus lives in &lt;strong>Cloud Storage&lt;/strong> (with object versioning), the analytical warehouse is &lt;strong>BigQuery&lt;/strong> (with &lt;strong>BigQuery Vector Search&lt;/strong> already integrated), the dedicated vector store is &lt;strong>Vertex AI Vector Search&lt;/strong> (formerly Matching Engine). The stream layer is &lt;strong>Pub/Sub&lt;/strong> + &lt;strong>Dataflow&lt;/strong>, CDC with &lt;strong>Datastream&lt;/strong>. Catalogue and lineage are &lt;strong>Dataplex&lt;/strong> (which in 2024-2025 absorbed Data Catalog and added automatic lineage). The managed equivalent of Knowledge Bases is &lt;strong>Vertex AI Search&lt;/strong> (formerly Discovery Engine).&lt;/p>
&lt;p>On &lt;strong>Azure&lt;/strong>, the corpus lives in &lt;strong>ADLS Gen2&lt;/strong>, tabular queries in &lt;strong>Microsoft Fabric&lt;/strong> / &lt;strong>Azure Synapse&lt;/strong>, the vector index in &lt;strong>Azure AI Search&lt;/strong> (vector mode) or &lt;strong>Azure Cosmos DB for PostgreSQL&lt;/strong> with pgvector. The stream layer is &lt;strong>Event Hubs&lt;/strong> + &lt;strong>Stream Analytics&lt;/strong> or &lt;strong>Microsoft Fabric Real-Time Intelligence&lt;/strong>, CDC with &lt;strong>Azure Data Factory&lt;/strong>. The catalogue is &lt;strong>Microsoft Purview&lt;/strong>, which covers catalogue, lineage and data governance integrated with Entra ID.&lt;/p>
&lt;p>&lt;strong>Summary table — Data stage.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Functional piece&lt;/th>
&lt;th>OSS on-premise&lt;/th>
&lt;th>AWS&lt;/th>
&lt;th>GCP&lt;/th>
&lt;th>Azure&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Object store&lt;/td>
&lt;td>MinIO, Ceph&lt;/td>
&lt;td>S3&lt;/td>
&lt;td>Cloud Storage&lt;/td>
&lt;td>ADLS Gen2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dataset versioning&lt;/td>
&lt;td>DVC, lakeFS&lt;/td>
&lt;td>S3 Versioning (limited), Lake Formation&lt;/td>
&lt;td>GCS Versioning, Dataplex&lt;/td>
&lt;td>ADLS versioning, Purview&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Vector index&lt;/td>
&lt;td>Qdrant, Milvus, pgvector&lt;/td>
&lt;td>OpenSearch, Aurora pgvector, Bedrock KB&lt;/td>
&lt;td>Vertex Vector Search, BigQuery VS&lt;/td>
&lt;td>Azure AI Search, Cosmos pgvector&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Stream + CDC&lt;/td>
&lt;td>Kafka + Debezium + Flink&lt;/td>
&lt;td>MSK / Kinesis + DMS + Glue&lt;/td>
&lt;td>Pub/Sub + Datastream + Dataflow&lt;/td>
&lt;td>Event Hubs + ADF&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Schema Registry&lt;/td>
&lt;td>Karapace, Confluent OSS&lt;/td>
&lt;td>Glue Schema Registry&lt;/td>
&lt;td>Pub/Sub schemas&lt;/td>
&lt;td>Schema Registry (Event Hubs)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Catalogue + lineage&lt;/td>
&lt;td>DataHub, Atlas, OpenLineage&lt;/td>
&lt;td>Glue Catalog + Lake Formation&lt;/td>
&lt;td>Dataplex&lt;/td>
&lt;td>Purview&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Managed end-to-end RAG&lt;/td>
&lt;td>— (you build it)&lt;/td>
&lt;td>Bedrock Knowledge Bases&lt;/td>
&lt;td>Vertex AI Search&lt;/td>
&lt;td>Azure AI Studio Knowledge&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Where the names mislead.&lt;/strong> &lt;em>S3 Versioning&lt;/em> is not DVC. It keeps object versions but has no notion of a &lt;strong>dataset&lt;/strong> (which objects together make up version 3 of the enriched set?), does not propagate &lt;code>dataset_hash&lt;/code> to the trainer, does not integrate with experiment tracking, and does not fail a CI run if a dataset breaks schema. Covering that properly on AWS requires combining it with Lake Formation, Glue Data Catalog and your own records in SageMaker Experiments. The same applies on GCP with Dataplex and on Azure with Purview. The gap is real and is paid for in operations or in broken lineage.&lt;/p>
&lt;h2 id="stage-2--tune">Stage 2 — Tune&lt;/h2>
&lt;p>&lt;strong>The problem.&lt;/strong> Producing a new &lt;code>model_id, model_version&lt;/code>, typically a LoRA adapter over a stable base, as the &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">post on continuous fine-tuning&lt;/a> explains, with lineage back to the dataset that trained it and experiment tracking that lets you reproduce it six months later.&lt;/p>
&lt;p>&lt;strong>OSS stack.&lt;/strong> Technical core: &lt;strong>HuggingFace Transformers + PEFT&lt;/strong> (LoRA, QLoRA), &lt;strong>bitsandbytes&lt;/strong> for quantisation, &lt;strong>DeepSpeed&lt;/strong> or &lt;strong>FSDP&lt;/strong> for parallelism. Experiment tracking: &lt;strong>MLflow&lt;/strong> (authoritative) or &lt;strong>Weights &amp;amp; Biases self-hosted&lt;/strong>. Convenience frameworks: &lt;strong>Axolotl&lt;/strong> and &lt;strong>Llama Factory&lt;/strong> wrap the machinery above in declarative configuration. Distributed orchestration: &lt;strong>Kubeflow Training Operator&lt;/strong> or &lt;strong>Ray Train&lt;/strong>. On small infrastructures, direct scripts with &lt;strong>Slurm&lt;/strong> or &lt;strong>K8s Jobs&lt;/strong> over GPU pools. The lineage chain &lt;code>dataset → run → model&lt;/code> is closed by registering the dataset as an MLflow input artefact.&lt;/p>
&lt;p>&lt;strong>Hyperscaler equivalents.&lt;/strong> On &lt;strong>AWS&lt;/strong>, &lt;strong>SageMaker Training Jobs&lt;/strong> covers most workloads, &lt;strong>SageMaker HyperPod&lt;/strong> covers large training runs with resilience to node failures, and &lt;strong>SageMaker JumpStart&lt;/strong> offers click-to-train fine-tuning over a catalogue of pre-curated models. For fine-tuning Bedrock models (hosted Claude, Llama, Mistral) there is &lt;strong>Bedrock Custom Models&lt;/strong>: you upload the dataset to S3, Bedrock trains, and it returns a private endpoint with provisioned throughput. The equivalent experiment tracking is &lt;strong>SageMaker Experiments&lt;/strong> or &lt;strong>managed MLflow in SageMaker&lt;/strong> (yes, AWS has officially hosted MLflow since 2024).&lt;/p>
&lt;p>On &lt;strong>GCP&lt;/strong>, &lt;strong>Vertex AI Custom Training&lt;/strong> runs any container with GPUs or TPUs; &lt;strong>Vertex AI Tuning&lt;/strong> is the managed API for fine-tuning Gemini and Model Garden models. Experiment tracking in &lt;strong>Vertex AI Experiments&lt;/strong> (with MLflow compatibility).&lt;/p>
&lt;p>On &lt;strong>Azure&lt;/strong>, &lt;strong>Azure ML Training Jobs&lt;/strong> on your own clusters or managed compute; &lt;strong>Azure OpenAI fine-tuning&lt;/strong> for fine-tuning GPT and the o-series; &lt;strong>Azure ML Experiments&lt;/strong> with MLflow natively integrated since 2022.&lt;/p>
&lt;p>&lt;strong>Summary table — Tune stage.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Functional piece&lt;/th>
&lt;th>OSS on-premise&lt;/th>
&lt;th>AWS&lt;/th>
&lt;th>GCP&lt;/th>
&lt;th>Azure&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Training framework&lt;/td>
&lt;td>HF Transformers + PEFT&lt;/td>
&lt;td>SageMaker SDK&lt;/td>
&lt;td>Vertex AI SDK&lt;/td>
&lt;td>Azure ML SDK&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quantisation / parallelism&lt;/td>
&lt;td>bitsandbytes, DeepSpeed, FSDP&lt;/td>
&lt;td>SageMaker libs + HF support&lt;/td>
&lt;td>Vertex + HF support&lt;/td>
&lt;td>Azure ML + HF support&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Managed fine-tuning (black box)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>Bedrock Custom Models, JumpStart&lt;/td>
&lt;td>Vertex Tuning (Gemini)&lt;/td>
&lt;td>Azure OpenAI fine-tuning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Distributed on a cluster&lt;/td>
&lt;td>Kubeflow, Ray Train, Slurm&lt;/td>
&lt;td>SageMaker HyperPod&lt;/td>
&lt;td>Vertex AI Training (multi-node)&lt;/td>
&lt;td>Azure ML compute clusters&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Experiment tracking&lt;/td>
&lt;td>MLflow, W&amp;amp;B self-hosted&lt;/td>
&lt;td>SageMaker Experiments, managed MLflow&lt;/td>
&lt;td>Vertex Experiments&lt;/td>
&lt;td>Azure ML + MLflow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Access to the base model&lt;/td>
&lt;td>Whatever you download (Llama, Mistral, Qwen)&lt;/td>
&lt;td>Bedrock catalog + HF Hub&lt;/td>
&lt;td>Vertex Model Garden + HF Hub&lt;/td>
&lt;td>Azure ML model catalog + HF Hub&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Where the names mislead.&lt;/strong> The &lt;em>managed&lt;/em> fine-tunings (Bedrock Custom, Vertex Tuning, AOAI fine-tuning) are a &lt;strong>black box&lt;/strong>: you do not choose hyperparameters beyond a handful, you do not see the trainer&amp;rsquo;s detailed logs, and you cannot inspect the dataset once it is inside their pipeline. The experiment tracking they offer is not comparable to MLflow sitting next to the trainer, where you can capture any metric and artefact. For scenarios where &lt;em>operationally&lt;/em> you do not need inspection this is liberating; for ENS / NIS2 scenarios where you have to prove what trained what, the black box fails by construction.&lt;/p>
&lt;h2 id="stage-3--eval">Stage 3 — Eval&lt;/h2>
&lt;p>&lt;strong>The problem.&lt;/strong> Validating candidates before and after promotion against a golden set, with operational metrics (faithfulness to the RAG, tone, format compliance, toxicity, jailbreak resistance, PII leakage) run as gates in CI and as online sampling. Covered in the &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">post on evals&lt;/a> and the one on &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails&lt;/a>.&lt;/p>
&lt;p>&lt;strong>OSS stack.&lt;/strong> Eval suites: &lt;strong>DeepEval&lt;/strong>, &lt;strong>RAGAS&lt;/strong> (specialised in RAG), &lt;strong>Promptfoo&lt;/strong> (declarative, ideal for CI), &lt;strong>lm-eval-harness&lt;/strong> (academic), &lt;strong>HELM&lt;/strong>. Evals integrated with tracing: &lt;strong>Langfuse Evals&lt;/strong>, &lt;strong>Phoenix Arize OSS&lt;/strong>. LLM-as-judge judges: any local OSS model; in serious systems, two different judges to reduce bias. Safety and guardrails: &lt;strong>NeMo Guardrails&lt;/strong> (NVIDIA), &lt;strong>Guardrails AI&lt;/strong>, &lt;strong>LlamaGuard&lt;/strong> + &lt;strong>PromptGuard&lt;/strong> (Meta), &lt;strong>ShieldGemma&lt;/strong> (Google, open weights), &lt;strong>PII detectors&lt;/strong> such as &lt;strong>Presidio&lt;/strong> (Microsoft) on-prem.&lt;/p>
&lt;p>&lt;strong>Hyperscaler equivalents.&lt;/strong> On &lt;strong>AWS&lt;/strong>, &lt;strong>Bedrock Model Evaluation&lt;/strong> offers automatic evals (toxicity, accuracy, robustness) and human-in-the-loop, &lt;strong>Bedrock Guardrails&lt;/strong> covers the safety layer (denied topics, PII, prompt injection, contextual grounding check), and &lt;strong>SageMaker Clarify&lt;/strong> adds bias and explainability over general models.&lt;/p>
&lt;p>On &lt;strong>GCP&lt;/strong>, &lt;strong>Vertex AI Evaluation Service&lt;/strong> runs evals with automatic metrics and an LLM judge, &lt;strong>Vertex AI Model Armor&lt;/strong> and the &lt;strong>safety filters&lt;/strong> built into the Gemini API cover the guardrails layer. &lt;strong>Vertex AI Studio&lt;/strong> exposes interactive Eval for prompt iteration.&lt;/p>
&lt;p>On &lt;strong>Azure&lt;/strong>, the &lt;strong>Azure AI Evaluation SDK&lt;/strong> runs offline evals against datasets, &lt;strong>Azure AI Content Safety&lt;/strong> covers safety (Prompt Shields against jailbreak, &lt;strong>Groundedness detection&lt;/strong>, content categories, &lt;strong>PII detection&lt;/strong>). All accessible from &lt;strong>Azure AI Foundry&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Summary table — Eval stage.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Functional piece&lt;/th>
&lt;th>OSS on-premise&lt;/th>
&lt;th>AWS&lt;/th>
&lt;th>GCP&lt;/th>
&lt;th>Azure&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Automatic eval suite&lt;/td>
&lt;td>DeepEval, RAGAS, Promptfoo&lt;/td>
&lt;td>Bedrock Model Evaluation&lt;/td>
&lt;td>Vertex AI Evaluation Service&lt;/td>
&lt;td>Azure AI Evaluation SDK&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LLM-as-judge&lt;/td>
&lt;td>Any OSS model&lt;/td>
&lt;td>Bedrock judge models&lt;/td>
&lt;td>Vertex judge (Gemini)&lt;/td>
&lt;td>Azure OpenAI judges&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Golden set management&lt;/td>
&lt;td>Langfuse datasets, manual&lt;/td>
&lt;td>SageMaker Ground Truth datasets&lt;/td>
&lt;td>Vertex Datasets&lt;/td>
&lt;td>Azure ML Datasets&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Guardrails (jailbreak, PII, prompt injection)&lt;/td>
&lt;td>NeMo Guardrails, LlamaGuard, Presidio&lt;/td>
&lt;td>Bedrock Guardrails&lt;/td>
&lt;td>Vertex Model Armor + Gemini safety&lt;/td>
&lt;td>Azure AI Content Safety (Prompt Shields, Groundedness)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Eval in CI&lt;/td>
&lt;td>Promptfoo + GitHub Actions&lt;/td>
&lt;td>Bedrock Eval API + CodeBuild&lt;/td>
&lt;td>Vertex Eval API + Cloud Build&lt;/td>
&lt;td>Azure AI Eval + Azure Pipelines&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Where the names mislead.&lt;/strong> Managed guardrails are convenient but &lt;strong>opaque&lt;/strong>: the Bedrock Guardrails rules are configurable but the detection implementation cannot be inspected; the same goes for Azure AI Content Safety. In OSS, NeMo Guardrails shows you the Colang graph and Presidio shows you the recognisers, auditable and modifiable. For regulated systems where an auditor asks &lt;em>&amp;ldquo;how exactly does it detect PII?&amp;rdquo;&lt;/em>, OSS answers with code; the cloud answers with documentation.&lt;/p>
&lt;h2 id="stage-4--deploy">Stage 4 — Deploy&lt;/h2>
&lt;p>&lt;strong>The problem.&lt;/strong> Serving tokens to the end user with predictable latency and throughput, a decent cost / token ratio, hot-swap adapter support, and multi-tenancy if the business demands it. Covered in the posts on &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on K8s&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM operators&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">multi-tenant cluster&lt;/a>.&lt;/p>
&lt;p>&lt;strong>OSS stack.&lt;/strong> Inference engine: &lt;strong>vLLM&lt;/strong> (PagedAttention, prefix caching, LoRA hot-swap, OpenAI-compatible API) as the reference, &lt;strong>TensorRT-LLM&lt;/strong> for maximum optimisation on Hopper / Ada, &lt;strong>SGLang&lt;/strong> for workloads with heavy prompt restructuring, &lt;strong>TGI&lt;/strong> (Hugging Face) as a mature alternative, &lt;strong>llama.cpp&lt;/strong> for edge and CPUs, &lt;strong>NVIDIA Dynamo&lt;/strong> for multi-node disaggregated serving on large clusters. Orchestration on Kubernetes: &lt;strong>KServe&lt;/strong>, &lt;strong>KubeRay&lt;/strong>, dedicated operators such as &lt;strong>llm-d&lt;/strong>, &lt;strong>vLLM Production Stack&lt;/strong> and &lt;strong>KAITO&lt;/strong>. Gateway / control plane: &lt;strong>Envoy AI Gateway&lt;/strong>, &lt;strong>LiteLLM Proxy&lt;/strong>, &lt;strong>Portkey AI Gateway&lt;/strong>, &lt;strong>Kong AI Gateway&lt;/strong>. Triton Inference Server covers mixed workloads (LLM + traditional) where a single backend matters.&lt;/p>
&lt;p>&lt;strong>Hyperscaler equivalents.&lt;/strong> On &lt;strong>AWS&lt;/strong>, two different routes. The &lt;em>managed-by-model&lt;/em> route is &lt;strong>Amazon Bedrock&lt;/strong>: a catalogue of hosted models (Claude, Llama, Mistral, Cohere, Titan), pay per token or &lt;strong>Provisioned Throughput&lt;/strong> with an SLA, &lt;strong>Bedrock Prompt Caching&lt;/strong> as the conceptual equivalent of vLLM&amp;rsquo;s prefix caching, and integrated &lt;strong>Bedrock Agents&lt;/strong> and &lt;strong>Bedrock Knowledge Bases&lt;/strong>. The &lt;em>managed-by-infrastructure&lt;/em> route is &lt;strong>SageMaker Endpoints&lt;/strong> (real-time, async, serverless, batch) with &lt;strong>Inference Components&lt;/strong> to densify multiple models on one instance. Own hardware: &lt;strong>AWS Inferentia&lt;/strong> and &lt;strong>Trainium&lt;/strong> via the &lt;strong>Neuron&lt;/strong> chip, an alternative to NVIDIA with a better cost / token on stable workloads if you compile your model.&lt;/p>
&lt;p>On &lt;strong>GCP&lt;/strong>, &lt;strong>Vertex AI Prediction Endpoints&lt;/strong> runs your containers or models from the &lt;strong>Model Garden&lt;/strong>, the &lt;strong>Gemini API&lt;/strong> via Vertex AI offers the managed Gemini models, and &lt;strong>Cloud TPU v5e / v5p / Trillium (v6)&lt;/strong> is the in-house hardware competing with the H100 for training and inference. For sovereignty there is &lt;strong>Google Distributed Cloud air-gapped&lt;/strong>, which brings Vertex AI to a certifiable on-premise rack.&lt;/p>
&lt;p>On &lt;strong>Azure&lt;/strong>, &lt;strong>Azure OpenAI Service&lt;/strong> serves OpenAI models (GPT-4.1, o-series, GPT-image), &lt;strong>Azure ML Managed Online Endpoints&lt;/strong> runs any model (OSS included, via container), and &lt;strong>Azure AI Foundry models&lt;/strong> absorbed in 2025 the catalogue of open models served as-a-service. Hardware: &lt;strong>Azure ND H100 v5&lt;/strong>, &lt;strong>ND H200 v5&lt;/strong>, &lt;strong>ND GB200 v6&lt;/strong> and the in-house bet &lt;strong>Microsoft Maia 100&lt;/strong> for internal inference.&lt;/p>
&lt;p>&lt;strong>Summary table — Deploy stage.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Functional piece&lt;/th>
&lt;th>OSS on-premise&lt;/th>
&lt;th>AWS&lt;/th>
&lt;th>GCP&lt;/th>
&lt;th>Azure&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Inference engine&lt;/td>
&lt;td>vLLM, TensorRT-LLM, SGLang, TGI&lt;/td>
&lt;td>Bedrock (managed model), SM Endpoints (your container)&lt;/td>
&lt;td>Vertex Prediction, Gemini API&lt;/td>
&lt;td>Azure OpenAI, Azure ML Endpoints&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Prefix / prompt caching&lt;/td>
&lt;td>vLLM native&lt;/td>
&lt;td>Bedrock Prompt Caching&lt;/td>
&lt;td>Vertex AI context caching&lt;/td>
&lt;td>Azure OpenAI prompt caching&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Adapter hot-swap (LoRA)&lt;/td>
&lt;td>vLLM &lt;code>--enable-lora&lt;/code>, S-LoRA&lt;/td>
&lt;td>Bedrock Custom Models endpoints&lt;/td>
&lt;td>Vertex Tuning endpoints&lt;/td>
&lt;td>Azure OpenAI fine-tuned deployments&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Disaggregated serving&lt;/td>
&lt;td>NVIDIA Dynamo, vLLM PD-disagg&lt;/td>
&lt;td>— (managed internally, not exposed)&lt;/td>
&lt;td>— (managed internally, not exposed)&lt;/td>
&lt;td>— (managed internally, not exposed)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Accelerator hardware&lt;/td>
&lt;td>NVIDIA H100/H200/B200, AMD MI300&lt;/td>
&lt;td>Inferentia, Trainium, NVIDIA&lt;/td>
&lt;td>TPU v5/v6, NVIDIA&lt;/td>
&lt;td>Maia, NVIDIA&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AI Gateway / proxy&lt;/td>
&lt;td>Envoy AI Gateway, LiteLLM, Portkey, Kong&lt;/td>
&lt;td>API Gateway + Bedrock&lt;/td>
&lt;td>Vertex AI + Apigee&lt;/td>
&lt;td>Azure API Management + AOAI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>K8s orchestration&lt;/td>
&lt;td>KServe, KubeRay, llm-d, KAITO&lt;/td>
&lt;td>EKS + SageMaker Operators&lt;/td>
&lt;td>GKE + Vertex AI&lt;/td>
&lt;td>AKS + KAITO&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Where the names mislead.&lt;/strong> Bedrock Prompt Caching and Vertex context caching &lt;strong>sound&lt;/strong> equivalent to vLLM&amp;rsquo;s prefix caching, but operationally they are different: the cache lives on the hyperscaler&amp;rsquo;s plane, its eviction policy is opaque, its cost is billed separately, and you cannot easily see the hit ratio per tenant. In vLLM you see the hit ratio in Prometheus metrics and you decide the policy. The same applies to disaggregated serving: the hyperscalers implement it internally to cut their own cost of serving, but they &lt;strong>do not expose&lt;/strong> prefill/decode control to the user. If you need your workload&amp;rsquo;s TTFT to be controlled separately from TPS, that lever is not available.&lt;/p>
&lt;h2 id="stage-5--observe">Stage 5 — Observe&lt;/h2>
&lt;p>&lt;strong>The problem.&lt;/strong> End-to-end LLM traces with &lt;code>trace_id&lt;/code> propagated through every component, runtime metrics per tenant, online scoring (LLM judge over sampling), statistical drift, and safety / guardrails monitoring. Covered in the posts on &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight tracing&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability with OTel&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a>.&lt;/p>
&lt;p>&lt;strong>OSS stack.&lt;/strong> Base standard: &lt;strong>OpenTelemetry&lt;/strong> (specification + collector + SDKs) with the &lt;strong>gen_ai semantic conventions&lt;/strong> that stabilised in 2025. Backends: &lt;strong>Tempo&lt;/strong> or &lt;strong>Jaeger&lt;/strong> for traces, &lt;strong>Prometheus&lt;/strong> for metrics, &lt;strong>Loki&lt;/strong> for logs, &lt;strong>Grafana&lt;/strong> as the common UI. LLM-specific layer: &lt;strong>Langfuse&lt;/strong> (self-hosted with an optional EE licence) and &lt;strong>Phoenix Arize OSS&lt;/strong>. eBPF layer for low-level observability: &lt;strong>Pixie&lt;/strong>, &lt;strong>Hubble&lt;/strong>, and &lt;strong>Cilium Tetragon&lt;/strong> for runtime security. Drift: &lt;strong>Evidently AI&lt;/strong>, &lt;strong>NannyML&lt;/strong>, &lt;strong>Alibi Detect&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Hyperscaler equivalents.&lt;/strong> On &lt;strong>AWS&lt;/strong>, &lt;strong>CloudWatch&lt;/strong> (metrics + logs) + &lt;strong>AWS X-Ray&lt;/strong> (traces) form the base, &lt;strong>CloudWatch Application Signals&lt;/strong> adds OTel-compatible APM, and &lt;strong>Amazon Managed Prometheus&lt;/strong> and &lt;strong>Amazon Managed Grafana&lt;/strong> serve the plane if you want to keep Prom + Grafana without operating them. &lt;strong>Bedrock logging&lt;/strong> integrates with CloudWatch and S3. &lt;strong>ADOT&lt;/strong> (AWS Distro for OpenTelemetry) is the official collector.&lt;/p>
&lt;p>On &lt;strong>GCP&lt;/strong>, &lt;strong>Cloud Monitoring&lt;/strong> + &lt;strong>Cloud Logging&lt;/strong> + &lt;strong>Cloud Trace&lt;/strong> + &lt;strong>Cloud Profiler&lt;/strong> form the quintet, all OTel compatible. &lt;strong>Vertex AI Model Monitoring&lt;/strong> offers drift detection (feature skew, prediction drift) integrated with runs.&lt;/p>
&lt;p>On &lt;strong>Azure&lt;/strong>, &lt;strong>Azure Monitor&lt;/strong> + &lt;strong>Application Insights&lt;/strong> + &lt;strong>Log Analytics&lt;/strong> cover the APM stack with native OTel, &lt;strong>Azure ML Model Monitor&lt;/strong> adds drift and data quality, and &lt;strong>Azure OpenAI diagnostic logs&lt;/strong> enrich the traces with token and model metadata.&lt;/p>
&lt;p>&lt;strong>Summary table — Observe stage.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Functional piece&lt;/th>
&lt;th>OSS on-premise&lt;/th>
&lt;th>AWS&lt;/th>
&lt;th>GCP&lt;/th>
&lt;th>Azure&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Traces (OTel)&lt;/td>
&lt;td>OTel + Tempo / Jaeger&lt;/td>
&lt;td>X-Ray + ADOT, App Signals&lt;/td>
&lt;td>Cloud Trace&lt;/td>
&lt;td>App Insights + Azure Monitor&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Metrics&lt;/td>
&lt;td>Prometheus + Grafana&lt;/td>
&lt;td>CloudWatch + AMP / AMG&lt;/td>
&lt;td>Cloud Monitoring&lt;/td>
&lt;td>Azure Monitor Metrics&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Logs&lt;/td>
&lt;td>Loki, ELK&lt;/td>
&lt;td>CloudWatch Logs&lt;/td>
&lt;td>Cloud Logging&lt;/td>
&lt;td>Log Analytics&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LLM-specific (prompt, scores, sessions)&lt;/td>
&lt;td>Langfuse, Phoenix Arize OSS&lt;/td>
&lt;td>Bedrock logging + CW + custom&lt;/td>
&lt;td>Vertex AI tracing + custom&lt;/td>
&lt;td>App Insights + AOAI logs + custom&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Drift detection&lt;/td>
&lt;td>Evidently, NannyML, Alibi Detect&lt;/td>
&lt;td>SageMaker Model Monitor&lt;/td>
&lt;td>Vertex AI Model Monitoring&lt;/td>
&lt;td>Azure ML Model Monitor&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>eBPF / runtime&lt;/td>
&lt;td>Pixie, Hubble, Tetragon&lt;/td>
&lt;td>— (no direct equivalent)&lt;/td>
&lt;td>GKE Dataplane v2 / Cloud Service Mesh&lt;/td>
&lt;td>Azure CNI + Defender for Cloud&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Where the names mislead.&lt;/strong> The classic cloud APM tools (X-Ray, Cloud Trace, App Insights) do not understand &lt;strong>prompt versioning&lt;/strong> or &lt;strong>adapter id&lt;/strong> as native concepts. They accept the &lt;code>gen_ai.*&lt;/code> attributes as dimensions, but the UIs do not prioritise those views. Langfuse and Phoenix do, because they are designed for LLM. In cloud, the usual pattern is to send dual: APM to the managed service for infrastructure + self-hosted Langfuse / Phoenix for the LLM plane. That pays off.&lt;/p>
&lt;h2 id="stage-6--retrain--cross-cutting-pieces">Stage 6 — Retrain + cross-cutting pieces&lt;/h2>
&lt;p>&lt;strong>The problem (Retrain).&lt;/strong> Closing the loop feedback → triage → enriched dataset → new adapter, with a mixed cadence (quarterly + incident-driven). Covered in the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain post&lt;/a>.&lt;/p>
&lt;p>&lt;strong>OSS Retrain stack.&lt;/strong> Orchestration: &lt;strong>Apache Airflow&lt;/strong>, &lt;strong>Prefect&lt;/strong>, &lt;strong>Dagster&lt;/strong> or &lt;strong>Argo Workflows&lt;/strong> and &lt;strong>Kubeflow Pipelines&lt;/strong> for K8s-native. Feature store where applicable: &lt;strong>Feast&lt;/strong>. Annotation and human-in-the-loop: &lt;strong>Argilla&lt;/strong>, &lt;strong>Label Studio&lt;/strong>, &lt;strong>Trubrics&lt;/strong>. Structured feedback capture: your own &lt;strong>Postgres&lt;/strong> table + &lt;strong>Langfuse scores&lt;/strong> + &lt;strong>Phoenix annotations&lt;/strong>. Closed-loop lineage: &lt;strong>OpenLineage&lt;/strong> tying dataset → run → model → deployment → feedback → next dataset.&lt;/p>
&lt;p>&lt;strong>Hyperscaler Retrain equivalents.&lt;/strong> On &lt;strong>AWS&lt;/strong>, &lt;strong>SageMaker Pipelines&lt;/strong> orchestrates the cycle, &lt;strong>SageMaker Ground Truth&lt;/strong> and &lt;strong>A2I&lt;/strong> (Augmented AI) manage annotation and HiL, and &lt;strong>SageMaker Model Monitor&lt;/strong> fires alerts that can invoke retrain pipelines. &lt;strong>AWS Step Functions&lt;/strong> serves as a more general alternative orchestrator.&lt;/p>
&lt;p>On &lt;strong>GCP&lt;/strong>, &lt;strong>Vertex AI Pipelines&lt;/strong> (based on Kubeflow Pipelines, compatible) orchestrates, &lt;strong>Vertex AI Data Labeling Service&lt;/strong> annotates, &lt;strong>Vertex AI Feature Store&lt;/strong> manages features, and &lt;strong>Workflows&lt;/strong> or &lt;strong>Cloud Composer&lt;/strong> (managed Airflow) act as orchestration alternatives.&lt;/p>
&lt;p>On &lt;strong>Azure&lt;/strong>, &lt;strong>Azure ML Pipelines&lt;/strong> orchestrates, &lt;strong>Azure ML Data Labeling&lt;/strong> annotates, &lt;strong>Azure ML Feature Store&lt;/strong> manages features.&lt;/p>
&lt;p>&lt;strong>The problem (cross-cutting: prompt + data versioning).&lt;/strong> Making sure &lt;code>prompt_id, prompt_version&lt;/code> and &lt;code>dataset_id, dataset_version&lt;/code> propagate through the whole system and show up in spans, runs and metrics. Covered in the posts on &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">prompt versioning&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Prompt versioning equivalents.&lt;/strong> OSS: &lt;strong>Langfuse Prompts&lt;/strong>, &lt;strong>MLflow Prompt Registry&lt;/strong>. AWS: &lt;strong>Bedrock Prompt Management&lt;/strong> (catalogue, versions, labels, integrated A/B testing) and &lt;strong>SageMaker Prompt Hub&lt;/strong>. GCP: &lt;strong>Vertex AI Prompt Management&lt;/strong> inside Vertex AI Studio. Azure: &lt;strong>Azure AI Foundry Prompt flow&lt;/strong> and prompt versioning in &lt;strong>Azure OpenAI deployments&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Data versioning equivalents.&lt;/strong> OSS: DVC + lakeFS (already covered under Data). AWS: S3 Versioning + Lake Formation + Glue Catalog (they are not DVC but together they cover part of it). GCP: Cloud Storage versioning + Dataplex (same). Azure: ADLS Gen2 versioning + Purview (same). The &lt;strong>real gap&lt;/strong> here is that no hyperscaler offers DVC natively, so dataset-as-first-class-citizen operations still require a layer of your own.&lt;/p>
&lt;p>&lt;strong>Summary table — Retrain stage + cross-cutting pieces.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Functional piece&lt;/th>
&lt;th>OSS on-premise&lt;/th>
&lt;th>AWS&lt;/th>
&lt;th>GCP&lt;/th>
&lt;th>Azure&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>ML pipeline orchestration&lt;/td>
&lt;td>Airflow, Dagster, Argo, Kubeflow&lt;/td>
&lt;td>SageMaker Pipelines, Step Functions&lt;/td>
&lt;td>Vertex AI Pipelines, Cloud Composer&lt;/td>
&lt;td>Azure ML Pipelines&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Feature store&lt;/td>
&lt;td>Feast&lt;/td>
&lt;td>SageMaker Feature Store&lt;/td>
&lt;td>Vertex AI Feature Store&lt;/td>
&lt;td>Azure ML Feature Store&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Annotation / HiL&lt;/td>
&lt;td>Argilla, Label Studio&lt;/td>
&lt;td>SageMaker Ground Truth, A2I&lt;/td>
&lt;td>Vertex Data Labeling&lt;/td>
&lt;td>Azure ML Data Labeling&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Feedback capture&lt;/td>
&lt;td>Postgres + Langfuse scores&lt;/td>
&lt;td>Bedrock + custom + Ground Truth&lt;/td>
&lt;td>Vertex + custom&lt;/td>
&lt;td>App Insights + custom&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Prompt versioning&lt;/td>
&lt;td>Langfuse Prompts, MLflow Prompts&lt;/td>
&lt;td>Bedrock Prompt Management&lt;/td>
&lt;td>Vertex Prompt Management&lt;/td>
&lt;td>Azure AI Foundry Prompt flow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Data versioning&lt;/td>
&lt;td>DVC + lakeFS + OpenLineage&lt;/td>
&lt;td>S3 Versioning + Lake Formation&lt;/td>
&lt;td>GCS + Dataplex&lt;/td>
&lt;td>ADLS + Purview&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cross-system lineage&lt;/td>
&lt;td>OpenLineage + DataHub&lt;/td>
&lt;td>SageMaker Lineage Tracking&lt;/td>
&lt;td>Dataplex lineage&lt;/td>
&lt;td>Purview&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="the-chatbot-from-the-previous-post-ported-to-aws">The chatbot from the previous post ported to AWS&lt;/h2>
&lt;p>To stop the catalogue being abstract, we take the full scenario from the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">previous post&lt;/a>, the multi-tenant support chatbot for insurers on an on-premise OSS stack, and describe it component by component with an AWS stack. It is not an executable migration; it is the &lt;strong>map of what disappears, what appears and where the lock-in shows up&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>The network plane.&lt;/strong> Edge LB and WAF: &lt;strong>AWS WAF + CloudFront&lt;/strong>. Ingress to the cluster: &lt;strong>AWS Load Balancer Controller&lt;/strong> over &lt;strong>EKS&lt;/strong>. What was Cilium BGP + RKE2 is replaced by EKS with &lt;strong>VPC CNI&lt;/strong> (or Cilium on EKS, which is possible). The conceptual equivalent of Tetragon is &lt;strong>Amazon GuardDuty for EKS&lt;/strong> + optional &lt;strong>Falco&lt;/strong>. Moderate lock-in: network control couples to VPC.&lt;/p>
&lt;p>&lt;strong>The chat gateway and auth.&lt;/strong> What was a bespoke API gateway with JWT verification materialises as &lt;strong>Amazon API Gateway&lt;/strong> + &lt;strong>Amazon Cognito&lt;/strong> (or IAM Identity Center if it is B2B). The gateway&amp;rsquo;s AI-aware routing is covered with &lt;strong>Bedrock&lt;/strong> + per-customer tags, or with &lt;strong>AWS API Gateway custom authorizers&lt;/strong> invoking a Lambda for tenant resolution. High lock-in on the identity layer if Cognito is chosen.&lt;/p>
&lt;p>&lt;strong>The inference engine.&lt;/strong> Three different options, with a clear trade-off.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Bedrock with a managed model&lt;/strong> (Claude / Llama / Mistral): all the operations of vLLM, K8s Operators, KV cache and disaggregated serving disappear. You move to &lt;strong>Provisioned Throughput&lt;/strong> for a latency guarantee. You gain time-to-market; you lose control over prefill/decode, over custom LoRA adapters (Bedrock accepts Bedrock-managed fine-tunes but not arbitrary LoRAs), and you enter model lock-in (moving from Claude to Llama means changing API).&lt;/li>
&lt;li>&lt;strong>SageMaker Endpoints with your own vLLM container&lt;/strong>: you keep vLLM and its optimisations, but K8s disappears and SageMaker replaces it as the orchestration plane. Inference Components allow you to densify multiple adapters. KV cache, prefix caching and LoRA hot-swap work the same. Moderate lock-in on the SageMaker SDK and on the Inference Components format.&lt;/li>
&lt;li>&lt;strong>EKS with vLLM&lt;/strong> (the minimalist option): basically the OSS stack but with EKS instead of RKE2 and EBS/EFS instead of Ceph. Low lock-in, limited benefit from the cloud.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Data layer.&lt;/strong> The corpus moves to &lt;strong>S3&lt;/strong> with versioning, the embeddings to &lt;strong>Amazon OpenSearch Service&lt;/strong> or to &lt;strong>Aurora pgvector&lt;/strong>. The radical managed option is &lt;strong>Bedrock Knowledge Bases&lt;/strong>: you upload documents to S3, it indexes them, it exposes a retrieval API. We remove Qdrant, we remove manual embedding pipelines, we remove part of Kafka + Flink. But control over custom reranking, fine-grained per-chunk ACL and the possibility of re-embedding with a new encoder of your own disappears. Bedrock KB uses the Titan or Cohere embedders available in Bedrock, and changing them means changing the whole index. ENS compliance: you have to validate that the buckets and the index live in EU regions and that the embedding model does too.&lt;/p>
&lt;p>&lt;strong>Stream + CDC.&lt;/strong> Kafka + Debezium is replaced by &lt;strong>MSK&lt;/strong> + &lt;strong>MSK Connect&lt;/strong> or by &lt;strong>Kinesis + DMS&lt;/strong>. Schema Registry: &lt;strong>Glue Schema Registry&lt;/strong>. The events remain functionally equivalent. Moderate lock-in if you go to Kinesis (Kinesis is not Kafka), low if you go to MSK (Kafka compatibility).&lt;/p>
&lt;p>&lt;strong>Data versioning.&lt;/strong> Here the gap is clear. S3 Versioning + Lake Formation + Glue Catalog &lt;strong>is not DVC&lt;/strong>. To preserve the discipline of the previous post, &lt;code>(dataset_id, dataset_version, sha256_hash)&lt;/code> propagated as an input artefact to the trainer, you can keep DVC over S3 (DVC works perfectly with S3 as a remote) or accept the limitation and register lineage manually in SageMaker Lineage Tracking. The first option preserves the practice; the second accepts degradation.&lt;/p>
&lt;p>&lt;strong>Tune stage.&lt;/strong> The LoRA adapter &lt;code>customer_support_v7&lt;/code> is trained with &lt;strong>SageMaker Training Jobs&lt;/strong> on &lt;strong>ml.p5.48xlarge&lt;/strong> instances (8× H100), using a standard HuggingFace + PEFT container. MLflow managed by SageMaker or your own MLflow on EC2 covers tracking. Alternative: if the black box is acceptable, &lt;strong>Bedrock Custom Models&lt;/strong> with a dataset in S3 produces a fine-tuned Bedrock model without instantiating a GPU manually, at the cost of not being able to inspect the run.&lt;/p>
&lt;p>&lt;strong>Eval stage.&lt;/strong> Promptfoo + RAGAS in CI runs the same over &lt;strong>CodeBuild&lt;/strong>. &lt;strong>Bedrock Model Evaluation&lt;/strong> replaces a good part of the automatic eval suite. &lt;strong>Bedrock Guardrails&lt;/strong> replaces NeMo Guardrails + Presidio + LlamaGuard, with the loss of transparency noted earlier.&lt;/p>
&lt;p>&lt;strong>Deploy stage.&lt;/strong> If Bedrock was chosen as the engine, this stage vanishes: Bedrock serves. If SageMaker Endpoints + vLLM was chosen, KServe is replaced by SageMaker Operators (or KServe is kept over EKS). The AI Gateway that in OSS was Envoy AI Gateway or LiteLLM becomes &lt;strong>API Gateway&lt;/strong> + &lt;strong>Bedrock&lt;/strong> or &lt;strong>API Gateway&lt;/strong> + &lt;strong>Lambda&lt;/strong> + &lt;strong>SageMaker&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Observe stage.&lt;/strong> The OTel Collector remains the standard. Traces to &lt;strong>AWS X-Ray&lt;/strong> + &lt;strong>CloudWatch Application Signals&lt;/strong>. Metrics to &lt;strong>Amazon Managed Prometheus&lt;/strong>. Logs to &lt;strong>CloudWatch Logs&lt;/strong> + optionally &lt;strong>OpenSearch&lt;/strong> for search. &lt;strong>Langfuse&lt;/strong> is hosted on &lt;strong>ECS Fargate&lt;/strong> or &lt;strong>EKS&lt;/strong> because the cloud has no native equivalent of integrated prompt + traces + scores. Drift: &lt;strong>SageMaker Model Monitor&lt;/strong> replaces Evidently / NannyML. eBPF (Pixie / Hubble / Tetragon) has &lt;strong>no direct equivalent&lt;/strong> in managed AWS, so Falco or installing Tetragon on EKS is still the route.&lt;/p>
&lt;p>&lt;strong>Retrain stage.&lt;/strong> &lt;strong>SageMaker Pipelines&lt;/strong> orchestrates the quarterly cycle. &lt;strong>SageMaker Ground Truth&lt;/strong> + &lt;strong>A2I&lt;/strong> replace Argilla. The &lt;code>feedback_signals&lt;/code> table in Postgres stays as it is (RDS Postgres) or moves to DynamoDB for large scales.&lt;/p>
&lt;p>&lt;strong>How much the lock-in weighs.&lt;/strong> The component with the highest lock-in is Bedrock + Bedrock Knowledge Bases + Bedrock Guardrails: leaving that requires rewriting the inference plane and reindexing the whole RAG. Next is the SageMaker SDK (Pipelines, Endpoints, Training), where leaving costs but means rewriting scripts, not data. Data in S3 is portable (S3 → MinIO with &lt;code>rclone&lt;/code> works). OTel observability is portable at almost no cost if the collector is kept as the abstraction. The auth gateway is the other high lock-in point if it uses Cognito.&lt;/p>
&lt;p>&lt;strong>What you gain.&lt;/strong> A dramatic reduction in GPU infrastructure operations, K8s patching, CUDA driver management, prefill/decode sizing, Ceph / MinIO management. A very short start-up curve: one request served in less than a sprint versus several weeks of bring-up for the OSS stack. Explicit provider SLAs.&lt;/p>
&lt;p>&lt;strong>What you lose.&lt;/strong> Contractual data sovereignty (the data stays in EU regions if configured that way, but the operator is a US third party under the Cloud Act). Visibility of the whole stack (Bedrock is a black box from the model downwards). Roadmap independence (the decision to discontinue a model, raise prices or change guardrails is not controlled by the customer). Fine-grained cost-per-token optimisation (the levers are the ones the provider exposes). For ENS customers under a HIGH declaration or NIS2 essential category, several of these points are &lt;strong>non-compliance&lt;/strong>, not preference.&lt;/p>
&lt;h2 id="master-table-the-entire-parallel-catalogue">Master table: the entire parallel catalogue&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage / component&lt;/th>
&lt;th>OSS on-premise (the blog&amp;rsquo;s reference)&lt;/th>
&lt;th>AWS&lt;/th>
&lt;th>GCP&lt;/th>
&lt;th>Azure&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Data&lt;/strong>&lt;/td>
&lt;td>DVC + lakeFS + MinIO + Qdrant + Kafka + Debezium&lt;/td>
&lt;td>S3 + Lake Formation + OpenSearch / Aurora pgvector + MSK + DMS&lt;/td>
&lt;td>GCS + Dataplex + Vertex Vector Search + Pub/Sub + Datastream&lt;/td>
&lt;td>ADLS Gen2 + Purview + Azure AI Search + Event Hubs + ADF&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Data versioning (cross-cutting)&lt;/strong>&lt;/td>
&lt;td>DVC + lakeFS + OpenLineage&lt;/td>
&lt;td>S3 Versioning + Lake Formation + Glue Catalog&lt;/td>
&lt;td>GCS Versioning + Dataplex lineage&lt;/td>
&lt;td>ADLS versioning + Purview&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tune&lt;/strong>&lt;/td>
&lt;td>HF Transformers + PEFT + bitsandbytes + MLflow + Ray/Kubeflow&lt;/td>
&lt;td>SageMaker Training + HyperPod + Bedrock Custom + SM Experiments&lt;/td>
&lt;td>Vertex AI Training + Vertex Tuning + Vertex Experiments&lt;/td>
&lt;td>Azure ML Training + Azure OpenAI fine-tuning + Azure ML + MLflow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Eval&lt;/strong>&lt;/td>
&lt;td>DeepEval + RAGAS + Promptfoo + Langfuse Evals + NeMo Guardrails&lt;/td>
&lt;td>Bedrock Model Evaluation + Bedrock Guardrails + SageMaker Clarify&lt;/td>
&lt;td>Vertex AI Evaluation Service + Model Armor + Gemini safety&lt;/td>
&lt;td>Azure AI Evaluation SDK + Content Safety (Prompt Shields, Groundedness)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Deploy&lt;/strong>&lt;/td>
&lt;td>vLLM + KServe + LLM Operators + Envoy AI Gateway&lt;/td>
&lt;td>Bedrock + SageMaker Endpoints (+ Inferentia / Trainium)&lt;/td>
&lt;td>Vertex AI Prediction + Gemini API (+ TPU)&lt;/td>
&lt;td>Azure OpenAI + Azure ML Endpoints (+ Maia)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Observe&lt;/strong>&lt;/td>
&lt;td>OTel + Tempo + Prometheus + Loki + Langfuse + Phoenix + Hubble&lt;/td>
&lt;td>CloudWatch + X-Ray + ADOT + AMP/AMG + SM Model Monitor&lt;/td>
&lt;td>Cloud Monitoring + Cloud Trace + Vertex Model Monitoring&lt;/td>
&lt;td>Azure Monitor + App Insights + Azure ML Model Monitor&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Retrain&lt;/strong>&lt;/td>
&lt;td>Airflow / Argo / Kubeflow Pipelines + Argilla + Feast&lt;/td>
&lt;td>SageMaker Pipelines + Ground Truth + A2I + SM Feature Store&lt;/td>
&lt;td>Vertex AI Pipelines + Data Labeling + Vertex Feature Store&lt;/td>
&lt;td>Azure ML Pipelines + Data Labeling + Azure ML Feature Store&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Prompt versioning (cross-cutting)&lt;/strong>&lt;/td>
&lt;td>Langfuse Prompts + MLflow Prompt Registry&lt;/td>
&lt;td>Bedrock Prompt Management + SM Prompt Hub&lt;/td>
&lt;td>Vertex AI Prompt Management&lt;/td>
&lt;td>Azure AI Foundry Prompt flow&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="when-to-choose-each-side--the-real-decision">When to choose each side — the real decision&lt;/h2>
&lt;p>The right question is not &lt;em>&amp;ldquo;OSS or cloud?&amp;rdquo;&lt;/em>. It is stage by stage.&lt;/p>
&lt;p>The &lt;strong>OSS side wins by default&lt;/strong> when there is:&lt;/p>
&lt;ul>
&lt;li>Data subject to ENS HIGH category, NIS2 essential sectors or equivalents (identifiable health data, regulated banking, critical infrastructure). Here provider traceability and the processing contract are not negotiable; using a service whose operator is subject to the Cloud Act, FISA 702 or equivalent compromises the legal basis.&lt;/li>
&lt;li>A requirement for auditable inspection of the model, the guardrails and the whole pipeline. If a regulator asks &lt;em>&amp;ldquo;how exactly does it detect PII?&amp;rdquo;&lt;/em> and an answer that ends in open code is mandatory.&lt;/li>
&lt;li>Large volumes with stable workloads. Above a certain threshold of tokens per month, the cost of Bedrock / AOAI / Vertex diverges from the amortised cost of your own GPU cluster. The threshold depends on the workload, but it typically sits between 5 and 50 billion tokens per month for models in the Llama 70B range.&lt;/li>
&lt;li>Roadmap independence as a priority. The day the provider discontinues a model or raises the price by 40 %, the organisation has to be able to ignore it.&lt;/li>
&lt;/ul>
&lt;p>The &lt;strong>hyperscaler side wins by default&lt;/strong> when there is:&lt;/p>
&lt;ul>
&lt;li>Critical time-to-market, an MVP in weeks. The operational weight of the OSS stack is too much for a project that has not yet proved product-market fit.&lt;/li>
&lt;li>A small team with no SREs / MLEs specialised in GPU inference. Operating KServe + vLLM + KV cache + multi-tenancy is not trivial; if the team cannot sustain it, hosting is the way.&lt;/li>
&lt;li>Variable workloads / unpredictable spikes. Bedrock on-demand and SageMaker serverless charge for what you use; your own cluster pays for the GPU whether it is busy or not.&lt;/li>
&lt;li>A need for specific proprietary models (Claude, GPT-4.1, Gemini Pro) with no acceptable OSS equivalent for the case.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Mixed stages&lt;/strong> are frequent and reasonable. In practice, a common pattern in 2026 is: data, observe and retrain in self-hosted OSS (lineage and sovereignty), tune in OSS on your own cluster, eval in OSS + managed guardrails depending on the safety profile, deploy managed for proprietary models and self-hosted for open models. The question to ask for each stage is: &lt;em>&amp;ldquo;if the provider raises prices by 50 % or discontinues a component tomorrow, how much does it cost to move it?&amp;rdquo;&lt;/em>. The parallel catalogue in this post gives the answer for each box.&lt;/p>
&lt;h2 id="what-we-have-not-covered-yet">What we have not covered (yet)&lt;/h2>
&lt;p>Some pieces still deserve a post of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OpenAI / Anthropic API directly&lt;/strong> (not through Bedrock or AOAI): another level of managed, another contract.&lt;/li>
&lt;li>&lt;strong>Serious hybrids&lt;/strong>: AWS Outposts, GCP Distributed Cloud, Azure Stack HCI / Azure Local, the hyperscaler in your own room.&lt;/li>
&lt;li>&lt;strong>Per-tenant cost accounting&lt;/strong> compared OSS vs cloud: how the bill is built and where attribution breaks.&lt;/li>
&lt;li>&lt;strong>A real migration&lt;/strong> OSS → cloud or cloud → OSS: steps, scripts, gotchas.&lt;/li>
&lt;li>&lt;strong>Concrete European sovereignty&lt;/strong>: GAIA-X, EuroHPC, the European cloud offering (OVHcloud, Scaleway, IONOS, Aruba), compared with the big three for ENS / NIS2 cases.&lt;/li>
&lt;li>&lt;strong>AWS Inferentia / Trainium, GCP TPU v6 Trillium, Azure Maia&lt;/strong>: in-house chips and how they change the cost / token calculation.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the forensic walk of a request through the six stages, the thread from which this post makes the vertical cut.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages: tool by tool&lt;/a> — the zoom into the open source side of this post&amp;rsquo;s master table: a card of about 150 words per core OSS tool (vLLM, Langfuse, DVC, Qdrant, Airflow, NeMo Guardrails, Presidio…), licence and governance, a decision matrix per stage and a diagram of the connected OSS stack.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map that this catalogue complements.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps specific to LLMs in 2026&lt;/a> — general context on why LLMOps is not classic MLOps.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning with DVC and lakeFS&lt;/a> — the deep-dive on the Data cross-cutting piece.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> — the deep-dive on the Prompt cross-cutting piece.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — Tune in detail.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> — Eval in detail.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — the safety layer in detail.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a> — Deploy from the inside.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM operators on K8s&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">Multi-tenant GPU cluster&lt;/a> — Deploy in operation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight LLM tracing&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability with OTel&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a> — Observe in its layers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop feedback → dataset → adapter&lt;/a> — Retrain in detail.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001: the operations manual of the AI system&lt;/a> — the lock-in and contractual sovereignty analysis in this post feeds directly into Annex A.10 (third parties and customer relationships) of the AIMS; the AI supplier register demands documenting exactly what is compared here column by column.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://docs.vllm.ai/">vLLM documentation&lt;/a> and &lt;a href="https://github.com/vllm-project/production-stack">vLLM Production Stack&lt;/a> — the reference OSS inference engine.&lt;/li>
&lt;li>&lt;a href="https://docs.aws.amazon.com/bedrock/">Amazon Bedrock documentation&lt;/a> — AWS managed model catalogue, Knowledge Bases, Guardrails and Prompt Management.&lt;/li>
&lt;li>&lt;a href="https://docs.aws.amazon.com/sagemaker/">Amazon SageMaker AI&lt;/a> — training, endpoints, pipelines, model monitoring.&lt;/li>
&lt;li>&lt;a href="https://cloud.google.com/vertex-ai/docs">Google Vertex AI documentation&lt;/a> — training, prediction, evaluation, model monitoring.&lt;/li>
&lt;li>&lt;a href="https://learn.microsoft.com/azure/ai-foundry/">Azure AI Foundry documentation&lt;/a> — Microsoft&amp;rsquo;s unified plane for AI applications.&lt;/li>
&lt;li>&lt;a href="https://learn.microsoft.com/azure/ai-services/openai/">Azure OpenAI Service documentation&lt;/a> — OpenAI models hosted on Azure.&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">OpenTelemetry GenAI semantic conventions&lt;/a> — the standard that stitches observability across the OSS / cloud boundary.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs">Langfuse documentation&lt;/a> and &lt;a href="https://docs.arize.com/phoenix">Arize Phoenix&lt;/a> — the reference OSS LLM observability tools.&lt;/li>
&lt;li>&lt;a href="https://dvc.org/">DVC&lt;/a> and &lt;a href="https://lakefs.io/">lakeFS&lt;/a> — OSS data versioning.&lt;/li>
&lt;li>&lt;a href="https://docs.nvidia.com/nemo/guardrails/">NeMo Guardrails&lt;/a> — OSS safety + dialog policy.&lt;/li>
&lt;li>ENS (Esquema Nacional de Seguridad) and NIS2 (Network and Information Security Directive 2) — the compliance frameworks that have the last word in the OSS vs cloud choice for regulated EU customers.&lt;/li>
&lt;/ul></description></item><item><title>Data versioning for LLMOps: DVC, lakeFS and the reproducible golden dataset challenge</title><link>https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/</link><pubDate>Fri, 22 May 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The Data stage of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a> has a silent link on which everything else depends: &lt;strong>versioning the datasets&lt;/strong> with the same discipline the code is versioned with. It is not optional. An LLM system in production consumes at least four distinct types of dataset (training/fine-tuning, RAG corpus, golden eval set, enriched dataset from the Retrain loop) and each has its own demands. Git solves code but fails on data for two technical reasons (size and a useless binary diff) and one operational one (it does not propagate lineage as far as the weights bucket of the trained model). The two dominant OSS tools, &lt;strong>DVC&lt;/strong> and &lt;strong>lakeFS&lt;/strong>, &lt;strong>unified in November 2025&lt;/strong> under a single organisation with a roadmap oriented towards LLM training and RAG datalakes; they are still complementary projects (file-level vs whole-bucket branching) but now under common governance. The productive pattern the market has consolidated on: identify each artefact with an immutable &lt;code>(dataset_id, version)&lt;/code>, propagate the pair as far as the experiment tracking (MLflow / W&amp;amp;B), version the dataset&amp;rsquo;s schema too (not just its content), apply a strict holdout to the golden eval set so you are not measuring memorisation, and maintain bidirectional traceability &lt;code>dataset_version ↔ model_version ↔ deployment ↔ trace_id&lt;/code>. Without this, the promise of &amp;ldquo;we can audit which model answered what&amp;rdquo; collapses at the first serious incident.&lt;/p>
&lt;h2 id="you-are-here-data-with-a-cross-cutting-effect-on-tune-eval-and-retrain">You are here: Data (with a cross-cutting effect on Tune, Eval and Retrain)&lt;/h2>
&lt;p>This post goes into the detail of the versioning link inside stage &lt;strong>1 · Data&lt;/strong>. Versioning belongs operationally to Data, but the artefacts it produces travel to Tune (training set), Eval (golden set) and Retrain (enriched dataset). That is why the diagram marks Data as active and shows a cross-cutting band indicating end-to-end lineage.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 135" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Data with cross-cutting lineage">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:3}.idle{fill:#f4f4f4}.cross{fill:#ffe9d6;stroke-width:1.4;stroke:#c66;rx:6}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#444}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#dvm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#dvm)}&lt;/style>
&lt;defs>&lt;marker id="dvm" 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: DATA · dataset versioning with lineage to the production trace&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box active"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;rect x="30" y="98" width="735" height="25" class="cross"/>
&lt;text x="397" y="115" text-anchor="middle" class="sm">Dataset lineage: training set → Tune · golden set → Eval · enriched set → Retrain (back to Data)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-master-analogy-batch-traceability-in-a-serious-factory">The master analogy: batch traceability in a serious factory&lt;/h2>
&lt;p>A serious pharmaceutical factory does not produce without &lt;strong>batch traceability&lt;/strong>. Every box of pills carries a printed batch number; that batch is associated with manufacturing dates, with the specific batches of each raw material used, with the quality tests it passed, and with the technicians who signed off each step. If a patient reports an adverse effect, the factory can rewind within hours: this package → this batch → these raw materials → this shift → this production line → this quality control result. Without that chain, the incident is a permanent mystery.&lt;/p>
&lt;p>A serious LLM system works the same way. The &amp;ldquo;package&amp;rdquo; is the answer a user saw in production. The &amp;ldquo;batch&amp;rdquo; is the combination of model, adapter, prompt, context and configuration that generated it. And the &amp;ldquo;raw materials&amp;rdquo; are the datasets: the training set the base model was trained on, the adapter&amp;rsquo;s fine-tuning dataset, the RAG corpus that feeds retrieval, the golden eval set that validates promotion. If a customer says &lt;em>&amp;ldquo;what data was the model trained on that answered X to my question Y on 14 March?&amp;rdquo;&lt;/em>, without batch traceability the answer is &lt;em>&amp;ldquo;we do not know&amp;rdquo;&lt;/em>. And that, with a customer under compliance pressure, kills the contract.&lt;/p>
&lt;p>Git versions the recipe (the code). Data versioning versions the ingredients. Without both, there is no auditable factory.&lt;/p>
&lt;h2 id="the-four-artefacts-worth-versioning-with-differentiated-demands">The four artefacts worth versioning (with differentiated demands)&lt;/h2>
&lt;p>Not all datasets are versioned the same way or at the same frequency. The typical LLM system in production handles four artefacts worth governing separately.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Artefact&lt;/th>
&lt;th>What it is&lt;/th>
&lt;th>Typical size&lt;/th>
&lt;th>New-version frequency&lt;/th>
&lt;th>Who consumes it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Training / fine-tuning dataset&lt;/strong>&lt;/td>
&lt;td>Input/output pairs (or conversations) that train the adapter or the model.&lt;/td>
&lt;td>10⁴ – 10⁷ examples · 1 – 100 GB&lt;/td>
&lt;td>Per Tune experiment&lt;/td>
&lt;td>Trainer (Axolotl, TRL, Unsloth)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RAG corpus&lt;/strong>&lt;/td>
&lt;td>Indexed documents that feed retrieval.&lt;/td>
&lt;td>10⁵ – 10⁹ chunks · 10 GB – 10 TB&lt;/td>
&lt;td>Almost continuous (streaming ingest)&lt;/td>
&lt;td>Indexer + vector store&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Golden eval set&lt;/strong>&lt;/td>
&lt;td>Curated examples with an expected answer to measure quality.&lt;/td>
&lt;td>10² – 10⁴ examples · MB&lt;/td>
&lt;td>Per product release&lt;/td>
&lt;td>Eval gates in CI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Enriched retrain dataset&lt;/strong>&lt;/td>
&lt;td>Cases where the system failed + the human correction.&lt;/td>
&lt;td>Hundreds to thousands per quarter&lt;/td>
&lt;td>Per &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain&lt;/a> cycle&lt;/td>
&lt;td>Next Tune&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>All four have common requirements (immutable identity, lineage, schema) and relevant differences:&lt;/p>
&lt;ul>
&lt;li>The &lt;strong>training set&lt;/strong> tends to be large, stable per experiment, and the cost of an error is a lost experiment (expensive but bounded).&lt;/li>
&lt;li>The &lt;strong>RAG corpus&lt;/strong> is enormous, in continuous change, and its versioning is handled through periodic snapshots of the index (not of the raw text). Usually lakeFS or bucket branches; DVC is not the best fit.&lt;/li>
&lt;li>The golden eval set is small but critical: errors here contaminate the whole promotion chain. Here the rigidity of the versioning matters more than anywhere else.&lt;/li>
&lt;li>The &lt;strong>enriched retrain dataset&lt;/strong> is incremental by nature: each Retrain cycle contributes a delta over the previous one. The new version does not overwrite; it inherits and adds.&lt;/li>
&lt;/ul>
&lt;p>Confusing them, treating the RAG corpus as if it were the training set, or the golden eval as if it were just another dataset, is the source of half the operational problems in data versioning.&lt;/p>
&lt;h2 id="why-git-is-not-enough">Why Git is not enough&lt;/h2>
&lt;p>The obvious question: if Git already solves code, why does it not solve data too? Three reasons, two technical and one operational.&lt;/p>
&lt;p>&lt;strong>Reason 1: size.&lt;/strong> A Git repository with a 50 GB dataset becomes unmanageable. &lt;code>git clone&lt;/code> pulls the entire history; &lt;code>git status&lt;/code> walks every file; the pack file in &lt;code>.git/objects&lt;/code> bloats to up to twice the size of the dataset. Git LFS solves the first part (the binary leaves the pack) but introduces its own complexity without addressing the other two reasons.&lt;/p>
&lt;p>&lt;strong>Reason 2: useless binary diff.&lt;/strong> Git assumes text diffs are useful. When a column changes in an 8 GB parquet, the diff is opaque, because the file is binary, compressed and columnar. You cannot do code review on a dataset change the way you do on a function change. You need a semantic diff: how many rows changed, which columns changed, which distribution moved. No native Git gives you that.&lt;/p>
&lt;p>&lt;strong>Reason 3: lineage that crosses repository boundaries.&lt;/strong> This is the most important and the most subtle. The training dataset lives in a bucket. The trainer code lives in a Git repo. The trained model is published to a model registry. Inference in production generates traces in an observability system. Connecting &lt;code>dataset_v3 → adapter_v7 → deployment_d2 → trace t_x9&lt;/code> requires propagating identifiers &lt;strong>across four different systems&lt;/strong>, not inside one repo. Git has no opinion on this.&lt;/p>
&lt;p>Data versioning tools (DVC, lakeFS, Pachyderm, Quilt) exist because they solve the three problems at once: they hang the data outside the Git repo, they offer some form of semantic diff, and they expose stable identities that can be propagated towards experiment tracking and the model registry.&lt;/p>
&lt;h2 id="dvc-vs-lakefs-before-the-unification">DVC vs lakeFS before the unification&lt;/h2>
&lt;p>Until November 2025, the two dominant OSS tools coexisted as complementary approaches.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Axis&lt;/th>
&lt;th>DVC&lt;/th>
&lt;th>lakeFS&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Mental model&lt;/td>
&lt;td>&amp;ldquo;Git for data&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;Branching for the data lake&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Granularity&lt;/td>
&lt;td>Individual file&lt;/td>
&lt;td>Whole bucket (with per-branch namespacing)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Storage&lt;/td>
&lt;td>Remote-agnostic (S3, GCS, Azure, MinIO, SSH)&lt;/td>
&lt;td>S3-compatible (S3, MinIO, Ceph)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Workflow&lt;/td>
&lt;td>&lt;code>dvc add&lt;/code> + &lt;code>dvc push&lt;/code> + &lt;code>dvc.yaml&lt;/code> pipelines&lt;/td>
&lt;td>&lt;code>lakectl commit&lt;/code> + branches/merges over the bucket&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Diff&lt;/td>
&lt;td>File hash + external metadata&lt;/td>
&lt;td>Object-level diff + commit log&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Strong cases&lt;/td>
&lt;td>Discrete training datasets, model files, reproducible pipelines&lt;/td>
&lt;td>Large RAG corpora, branching a shared data lake, parallel experiments without duplicating data&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Git integration&lt;/td>
&lt;td>Deep (the &lt;code>.dvc&lt;/code> files are committed to Git)&lt;/td>
&lt;td>Tangential (lakeFS lives alongside)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Who operates it&lt;/td>
&lt;td>MLE team&lt;/td>
&lt;td>Data engineering team&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>In practice, many teams used them at the same time: DVC for the discrete datasets that fed an experiment (they fit in a Git repo thanks to the indirection of the &lt;code>.dvc&lt;/code> pointers), and lakeFS for the large RAG corpus bucket they wanted to branch without duplicating terabytes.&lt;/p>
&lt;h2 id="what-changed-with-the-november-2025-acquisition">What changed with the November 2025 acquisition&lt;/h2>
&lt;p>&lt;a href="https://medium.com/the-modern-scientist/reproducible-ai-versioning-models-prompts-and-data-96dd0337af65">lakeFS acquired DVC&lt;/a> in November 2025. The operational consequence as of May 2026 is modest but relevant:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>There is (as yet) no technical merger of the projects.&lt;/strong> DVC is still DVC and lakeFS is still lakeFS. The current CLIs, formats and workflows have not changed.&lt;/li>
&lt;li>&lt;strong>An explicit combined roadmap towards LLM training and RAG datalakes.&lt;/strong> The merged organisation has stated specific priorities: consistent branching between the dataset and the trained model, native integrations with MLflow / W&amp;amp;B / Langfuse, support for the typical LLM formats (jsonl, parquet with embedded tokenisation), and branch-aware vector indexing.&lt;/li>
&lt;li>&lt;strong>Convergence expected in 2026-2027.&lt;/strong> The market anticipates a single registry with two operating modes (file-level + bucket-branching) under a unified CLI. As of today, teams still combine both.&lt;/li>
&lt;/ul>
&lt;p>The practical reading for 2026: &lt;strong>adopt DVC for discrete training/eval datasets and lakeFS for the RAG corpus&lt;/strong>, but design the lineage so that a future unified registry can absorb both without re-versioning everything. Concretely: use stable identifiers (&lt;code>dataset_id&lt;/code>, &lt;code>version&lt;/code>, &lt;code>commit_hash&lt;/code>) that can be propagated regardless of the tool.&lt;/p>
&lt;h2 id="the-operational-pattern-four-hop-lineage">The operational pattern: four-hop lineage&lt;/h2>
&lt;p>Once you accept that datasets have to be versioned, the question is not &amp;ldquo;which tool&amp;rdquo; but &amp;ldquo;which chain of identifiers connects production to the source data&amp;rdquo;. The pattern the market has consolidated on has four hops:&lt;/p>
&lt;pre tabindex="0">&lt;code>(dataset_id, dataset_version)
│ versioned in DVC or lakeFS
▼
(model_id, model_version)
│ registered in MLflow / W&amp;amp;B with the dataset as input
▼
(deployment_id, prompt_version)
│ registered in the model registry + prompt registry
▼
(trace_id)
│ emitted by the inference engine with OTel
▼
answer visible to the user
&lt;/code>&lt;/pre>&lt;p>Each arrow is a metadata write that crosses the boundary between two systems. If a single arrow is missing, the lineage breaks and the promise of auditability evaporates.&lt;/p>
&lt;p>A concrete example of the flow, using DVC + MLflow:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Data stage: version the dataset&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">dvc add data/finetune_v3.jsonl
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">git add data/finetune_v3.jsonl.dvc data/.gitignore
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">git commit -m &lt;span class="s2">&amp;#34;data: finetune dataset v3&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">dvc push &lt;span class="c1"># uploads the binary to the remote (MinIO/S3)&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"># Tune stage: train while recording lineage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mlflow run train.py &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">dataset_id&lt;/span>&lt;span class="o">=&lt;/span>finetune &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">dataset_version&lt;/span>&lt;span class="o">=&lt;/span>v3 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">dataset_hash&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>dvc get-url data/finetune_v3.jsonl &lt;span class="p">|&lt;/span> sha256sum&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># The run records: input dataset + model output&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"># Eval stage: validate while recording lineage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mlflow run eval.py &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">model_id&lt;/span>&lt;span class="o">=&lt;/span>adapter_customer_v7 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">golden_set_id&lt;/span>&lt;span class="o">=&lt;/span>customer_support &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">golden_set_version&lt;/span>&lt;span class="o">=&lt;/span>v12
&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"># Deploy stage: the deployment inherits the dataset + golden ids&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Every trace in Observe carries model_version + prompt_version&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># which rewind back to dataset_version&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The equivalent version with lakeFS over the RAG corpus:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Branch for the new corpus embeddings&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lakectl branch create lakefs://corpus/embed-2026q2 --source main
&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"># Index the corpus on that branch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python index_corpus.py --branch embed-2026q2
&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"># Validate before merging to main&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python eval_retrieval.py --branch embed-2026q2 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --metric recall@10 --threshold 0.78
&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"># If it passes, merge (changes the corpus that serves production)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lakectl commit lakefs://corpus/embed-2026q2 -m &lt;span class="s2">&amp;#34;embed: corpus 2026q2&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lakectl merge lakefs://corpus/embed-2026q2 lakefs://corpus/main
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The virtue of the second flow: while the new corpus is being validated, the production system keeps serving from &lt;code>main&lt;/code> with no interference. The parallel branch works as a real staging area over the whole bucket.&lt;/p>
&lt;h2 id="schema-contracts-data-versioning-without-them-is-an-illusion">Schema contracts: data versioning without them is an illusion&lt;/h2>
&lt;p>Versioning a dataset&amp;rsquo;s content without versioning its &lt;strong>schema&lt;/strong> is a frequent mistake. The problem: a versioned dataset with an implicit schema still breaks silently when a producer (the ingestion team, the annotation team, an ad-hoc script) changes a field.&lt;/p>
&lt;p>A concrete case: a customer support golden eval set, 1000 examples, an &lt;code>expected_output&lt;/code> field originally &lt;code>string&lt;/code>. Someone decides they need to capture several valid answers and changes the field to &lt;code>list[string]&lt;/code>. The eval loader accepts both formats by chance (Python is lax) but the downstream LLM judge receives a different object. The eval keeps passing but now it measures something else.&lt;/p>
&lt;p>Productive pattern: the dataset is versioned with DVC/lakeFS and its schema is versioned with a &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">Schema Registry&lt;/a> (Confluent or Apicurio) or, in less mature systems, with a JSON Schema embedded alongside the dataset. CI blocks any PR that breaks the contract without a version bump.&lt;/p>
&lt;p>A minimal schema for a golden eval entry (illustrative):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">$schema&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://json-schema.org/draft/2020-12/schema&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">$id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://example.org/schemas/golden_eval_entry/v3.json&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">object&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">required&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">example_id, input, expected_outputs, rubric, segment]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">properties&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">example_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: string, format&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">uuid}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">input&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">object&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">required&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">user_query, retrieved_context]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">properties&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retrieved_context&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: array, items&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">expected_outputs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">array&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minItems&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">items&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rubric&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">object&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">required&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">must_include, must_not_include, format]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">properties&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">must_include&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: array, items&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">must_not_include&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: array, items&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">format&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">enum&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">text, json, markdown]}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">segment&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">difficulty&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">enum&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">easy, medium, hard]}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">added_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: string, format&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">date-time}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">curated_by&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Operational rules:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Explicit forward/backward compatibility&lt;/strong>: adding an optional field is backward-compatible; removing a required one is breaking. The policy is enforced with a compatibility check in CI.&lt;/li>
&lt;li>&lt;strong>The schema version embedded&lt;/strong> in every row of the dataset (a &lt;code>_schema_version&lt;/code> field). The loader validates that the version matches what the consuming code expects.&lt;/li>
&lt;li>&lt;strong>The schema registry as the single source of truth&lt;/strong>, not as an optional copy of the JSON Schema in four repos.&lt;/li>
&lt;/ul>
&lt;p>Without this level of discipline, &amp;ldquo;we have data versioning&amp;rdquo; means &amp;ldquo;we store the bytes but we do not control what they mean&amp;rdquo;.&lt;/p>
&lt;h2 id="golden-eval-set-the-most-critical-version">Golden eval set: the most critical version&lt;/h2>
&lt;p>Of the four artefacts, the &lt;strong>golden eval set&lt;/strong> is the one that demands the most rigour. A failure here contaminates the whole promotion chain: if the eval lies, the gates approve models that should not pass.&lt;/p>
&lt;p>Three extra disciplines over the golden set:&lt;/p>
&lt;p>&lt;strong>Annotation with measured quality.&lt;/strong> Each example is labelled by a human, and a percentage (10-20 %) is annotated by two independent people. The &lt;strong>inter-annotator agreement&lt;/strong> (Cohen&amp;rsquo;s kappa or pairwise F1) is measured and published; a golden set with kappa &amp;lt; 0.7 is measuring human noise, not model behaviour. Argilla and Label Studio provide the mechanics; what matters is the discipline, not the tool.&lt;/p>
&lt;p>&lt;strong>A strict holdout against contamination.&lt;/strong> The golden set must &lt;strong>never&lt;/strong> enter the training set. The concrete mechanism: hash each golden set &lt;code>input&lt;/code> (sha256 normalised by lowercasing + stripping trivial punctuation) → check in CI against all the training set hashes. If there is an intersection, CI blocks until it is resolved. Without this check, the model passes the eval by memorisation, not by capability. The consequence in production is disastrous: the &amp;ldquo;validated&amp;rdquo; model fails on cases analogous to the golden set that it had not memorised.&lt;/p>
&lt;p>&lt;strong>Additive versioning, never destructive.&lt;/strong> When the golden set grows (each retrain cycle adds cases), &lt;code>golden_v3 = golden_v2 ∪ new_examples&lt;/code>. Never &lt;code>golden_v3 = a different new set&lt;/code>. Only that way can you compare two models trained months apart over the same base + the new delta. If you rewrite the golden set, you cannot tell whether the March model was worse than the May one or whether you were simply measuring different things.&lt;/p>
&lt;p>Summary table of the discipline per artefact:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Practice&lt;/th>
&lt;th>Training set&lt;/th>
&lt;th>RAG corpus&lt;/th>
&lt;th>Golden eval set&lt;/th>
&lt;th>Enriched retrain&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Immutable versioning&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes (snapshots)&lt;/td>
&lt;td>&lt;strong>Yes, critical&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Schema with a contract&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Recommended&lt;/td>
&lt;td>&lt;strong>Yes, critical&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Double annotation&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Not applicable&lt;/td>
&lt;td>&lt;strong>Yes (10-20 %)&lt;/strong>&lt;/td>
&lt;td>Yes (10-20 %)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Holdout vs other datasets&lt;/td>
&lt;td>N/A&lt;/td>
&lt;td>N/A&lt;/td>
&lt;td>&lt;strong>Yes, hash check&lt;/strong>&lt;/td>
&lt;td>Yes (vs golden)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Drift check vs previous version&lt;/td>
&lt;td>Recommended&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Recommended&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Lineage to deployment&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="promotion-gates-the-dataset-is-promoted-like-the-model">Promotion gates: the dataset is promoted like the model&lt;/h2>
&lt;p>A candidate dataset (a freshly enriched &lt;code>golden_v13&lt;/code>, an &lt;code>enriched_retrain_2026_q2&lt;/code> resulting from the Retrain cycle) does not enter production just by being in the bucket. It goes through gates equivalent to the model&amp;rsquo;s or the prompt&amp;rsquo;s:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Schema validation&lt;/strong> — the contract is met. Blocked in CI if not.&lt;/li>
&lt;li>&lt;strong>Quality validation&lt;/strong> — a random 5-10 % sample reviewed by a human with a quality score ≥ 4/5. Blocked if the sample fails.&lt;/li>
&lt;li>&lt;strong>Holdout segregation check&lt;/strong> — for golden sets and enriched datasets, a hash check against all the other active datasets. Blocked if there is overlap.&lt;/li>
&lt;li>&lt;strong>Drift check vs previous version&lt;/strong> — a KS test over the distribution of the input embeddings, or simpler metrics (mean length, segment distribution, the ratio of each label). A warning if the drift is high with no documented cause; a block if it is very high.&lt;/li>
&lt;li>&lt;strong>Lineage check&lt;/strong> — the dataset explicitly declares which version it inherits from and what changed. Without that metadata, it does not go in.&lt;/li>
&lt;/ol>
&lt;p>Only when all five gates pass is the dataset tagged &lt;code>production-ready&lt;/code> and the downstream pipelines that depend on it unblocked (the next Tune, the next product release, the next eval cycle).&lt;/p>
&lt;h2 id="the-on-premise-stack-applied">The on-premise stack applied&lt;/h2>
&lt;p>On a generic infrastructure with an &lt;strong>RTX 4090&lt;/strong> (24 GB VRAM, a development / small-batch profile) and a &lt;strong>4×H100 SXM cluster&lt;/strong> (80 GB VRAM each, NVLink, training and production inference), data versioning fits without a dedicated GPU for the versioning itself, since versioning lives on CPU + storage, but it does touch the GPU for the drift checks that require embeddings.&lt;/p>
&lt;p>Typical topology:&lt;/p>
&lt;pre tabindex="0">&lt;code>┌────────────────────────────────────────────────────────────┐
│ Object store (MinIO or Ceph) │
│ buckets: /training-sets /corpus-rag │
│ /golden-evals /enriched-retrain │
└────────────────────────┬───────────────────────────────────┘
│
┌─────────────────┼──────────────────┐
│ │ │
┌───▼────┐ ┌────▼────┐ ┌────▼─────┐
│ DVC │ │ lakeFS │ │ MLflow │
│ remote │ │ branches│ │ Tracking │
└───┬────┘ └────┬────┘ └────┬─────┘
│ │ │
└─────────────────┴──────────────────┘
│
┌──────▼──────┐
│ CI/CD gates │
│ (Forgejo / │
│ GitLab) │
└──────┬──────┘
│
┌──────────┴───────────┐
│ │
┌─────▼──────┐ ┌─────▼─────┐
│ RTX 4090 │ │ 4×H100 │
│ (drift │ │ (training │
│ embeds, │ │ + │
│ validates)│ │ serving) │
└────────────┘ └───────────┘
&lt;/code>&lt;/pre>&lt;p>Operational notes:&lt;/p>
&lt;ul>
&lt;li>The &lt;strong>object store&lt;/strong> (MinIO or Ceph) serves both as the DVC remote and as lakeFS storage. One storage plane, two views.&lt;/li>
&lt;li>The &lt;strong>schema checks&lt;/strong> and &lt;strong>holdout hashing&lt;/strong> are fast CPU-bound tasks; the CI runner executes them without a GPU.&lt;/li>
&lt;li>The &lt;strong>embedding drift check&lt;/strong> needs an encoder; the RTX 4090 handles this without touching the production cluster. A small encoder (BGE-small, E5-small, ~100M parameters) processes 10⁴ examples in a few minutes.&lt;/li>
&lt;li>The &lt;strong>H100 cluster&lt;/strong> stays free for training and serving, with no contamination from versioning jobs.&lt;/li>
&lt;/ul>
&lt;h3 id="when-do-you-not-need-dvclakefs">When do you NOT need DVC/lakeFS?&lt;/h3>
&lt;p>There is an opposite position defended with figures in the &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning post&lt;/a>: for small systems with a single team, datasets &amp;lt; 1 GB and a handful of adapters, &lt;strong>Postgres + pgvector + an S3 bucket + hashed filenames&lt;/strong> are enough. The operational complexity of DVC/lakeFS does not pay for itself.&lt;/p>
&lt;p>The dividing line is reasonable:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>You do not need DVC/lakeFS&lt;/strong>: a single team, small datasets, few adapters, no multiple products sharing data.&lt;/li>
&lt;li>&lt;strong>You do need it&lt;/strong>: multiple teams, datasets &amp;gt; 10 GB, several products sharing a golden eval set, external compliance demanding batch traceability, or an institutionalised quarterly retrain cycle.&lt;/li>
&lt;/ul>
&lt;p>Adopting DVC + lakeFS before you need them is overhead. Adopting them six months late is losing six months of lineage irrecoverably.&lt;/p>
&lt;h2 id="seven-pitfalls-that-turn-data-versioning-into-theatre">Seven pitfalls that turn data versioning into theatre&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Versioning the data but not the schemas.&lt;/strong> The content is versioned, the contract changes silently, the system breaks without the versioning capturing it. A Schema Registry is not optional; it is half the problem.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>The same S3 path overwritten.&lt;/strong> &amp;ldquo;Upload &lt;code>training.jsonl&lt;/code> to the bucket&amp;rdquo; and the next experiment rewrites the file. S3 versioning (if it is enabled) saves your skin, but without an immutable identifier propagated to MLflow you cannot rewind. The correct pattern: &lt;code>training_v3.jsonl&lt;/code> or &lt;code>training/2026q2/&amp;lt;sha&amp;gt;.jsonl&lt;/code>, never the same name.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>A golden eval set without a strict holdout.&lt;/strong> Without a hash check against training, the model memorises the eval and passes without having learned. It is the LLM equivalent of an exam the teacher announces in advance: everyone passes, nothing has been measured.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Not recording dataset → model lineage.&lt;/strong> When an incident requires knowing what data a certain model was trained on, the correct answer is a query to MLflow / W&amp;amp;B. If the answer is &amp;ldquo;let&amp;rsquo;s ask whoever trained it&amp;rdquo; (assuming they are still on the team), the lineage does not exist.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>DVC added six months late.&lt;/strong> Adopting versioning in month 1 = a nuisance. Adopting it in month 6 = an irrecoverable loss of six months of datasets that can no longer be reconstructed. The curse of &amp;ldquo;we&amp;rsquo;ll add it later&amp;rdquo;.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>lakeFS with branches that never get merged.&lt;/strong> Parallel branches over the corpus are useful for experimenting; kept indefinitely without merging, operations turn into a graveyard of half-updated branches. Explicit policy: merge or destroy within N weeks.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Schema validation only in production.&lt;/strong> The contract is validated when the dataset is already in production and the model already trained. By then, the incident has already happened. The validation has to be &lt;strong>in CI&lt;/strong>, before the merge, over the delta the PR introduces.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="a-datasets-cycle-on-one-screen">A dataset&amp;rsquo;s cycle on one screen&lt;/h2>
&lt;pre tabindex="0">&lt;code>┌─────────────────────────────────────────────────────────────┐
│ Producer (ingest / annotation / retrain loop) │
└────────────────┬────────────────────────────────────────────┘
│
▼ (commit a candidate version)
┌─────────────────────────┐
│ CI gates │
│ - Schema validation │
│ - Quality sampled │
│ - Holdout hash check │ ── fails → PR blocked
│ - Drift vs previous │
│ - Lineage declared │
└────────────┬────────────┘
│ passes
▼
┌─────────────────────────┐
│ DVC tag or lakeFS commit│
│ + MLflow registry │ ← immutable version
│ + Schema Registry │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Downstream pipeline │
│ Tune / Eval / Deploy │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Production trace │
│ → rewinds to dataset │
└─────────────────────────┘
&lt;/code>&lt;/pre>&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;p>At top level, this post leaves out:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Vector store versioning&lt;/strong> proper: an embedding index is not versioned like a raw dataset because it depends on the embedding model. Changing the embedder rewrites the whole index. It is a different animal and deserves separate treatment (recall, ANN parameters, index branching vs full re-embedding).&lt;/li>
&lt;li>&lt;strong>Standardised lineage tooling&lt;/strong> (OpenLineage, Marquez): how to emit and consume lineage events interoperably between systems.&lt;/li>
&lt;li>&lt;strong>Data quality frameworks&lt;/strong> (Great Expectations, Soda, Deequ): how to write suites of &amp;ldquo;expectations&amp;rdquo; over a dataset and enforce them on every version.&lt;/li>
&lt;li>&lt;strong>Privacy-preserving versioning&lt;/strong>: federated learning without centralising the dataset, differential privacy applied to the version that gets distributed.&lt;/li>
&lt;li>&lt;strong>Contamination between third-party golden sets&lt;/strong> (HumanEval, MMLU, etc.) and the training datasets of open models: the problem of &amp;ldquo;the model passes HumanEval because HumanEval is in its pretraining&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;p>Each one is worth a post of its own when the field justifies it.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where this piece fits, the Data section.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop&lt;/a> — how the enriched dataset produced by Retrain returns to Data; this post details how to version it properly.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> — the other cross-cutting piece of the lineage; the &lt;code>prompt_version&lt;/code> travels alongside the &lt;code>dataset_version&lt;/code> in every trace.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — argues for a minimalist stack (Postgres + pgvector + S3) without DVC/lakeFS for small systems; this post explains when you cross the line to the other side.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs: the layer after tracing&lt;/a> — the main consumer of the golden eval set.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">LLM-specific MLOps in 2026&lt;/a> — market context for the complete LLMOps stack.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant for ingestion&lt;/a> — how the ingestion that precedes versioning is materialised.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001: the AI system&amp;rsquo;s operations manual&lt;/a> — the versioning discipline described here materialises the five controls of Annex A.7 (data quality, acquisition, provenance, preparation) directly, with no additional work.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">EU AI Act: the technical file article by article&lt;/a> — Art. 10 (data and data governance) requires the four datasets versioned + bias analysis + lineage + representativeness. This post covers the technical mechanics; that one covers the legal obligations and the auditable checklist.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://dvc.org/doc">DVC documentation&lt;/a> — versioning workflows, pipelines and remotes.&lt;/li>
&lt;li>&lt;a href="https://docs.lakefs.io/">lakeFS documentation&lt;/a> — branching, merging and commits over the bucket.&lt;/li>
&lt;li>&lt;a href="https://medium.com/the-modern-scientist/reproducible-ai-versioning-models-prompts-and-data-96dd0337af65">lakeFS acquires DVC, November 2025&lt;/a> — announcement and combined roadmap.&lt;/li>
&lt;li>&lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html">Confluent Schema Registry&lt;/a> and &lt;a href="https://www.apicur.io/registry/">Apicurio&lt;/a> — schema contracts for streaming data.&lt;/li>
&lt;li>&lt;a href="https://openlineage.io/">OpenLineage&lt;/a> and &lt;a href="https://marquezproject.ai/">Marquez&lt;/a> — open standard for lineage events.&lt;/li>
&lt;li>&lt;a href="https://greatexpectations.io/">Great Expectations&lt;/a> — data quality expectations in CI.&lt;/li>
&lt;li>&lt;a href="https://mlflow.org/docs/latest/tracking.html">MLflow Tracking&lt;/a> — input datasets as first-class artefacts since MLflow 2.4.&lt;/li>
&lt;li>&lt;a href="https://www.pachyderm.com/">Pachyderm&lt;/a> and &lt;a href="https://quiltdata.com/">Quilt&lt;/a> — historical alternatives to DVC/lakeFS.&lt;/li>
&lt;li>On eval set contamination: &lt;em>&amp;ldquo;Stop Uploading Test Data in Plain Text&amp;rdquo;&lt;/em> (Magar &amp;amp; Schwartz, 2022) and later work on detecting contamination in pretraining corpora.&lt;/li>
&lt;/ul></description></item><item><title>Retrain: closing the loop between the production incident and the adapter that fixes it</title><link>https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/</link><pubDate>Fri, 22 May 2026 07:45:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;strong>Retrain&lt;/strong> stage of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a> is the one that closes the cycle. Without it, the deployed system is a project that ends; with it, it is a living practice that improves every quarter. The mechanics at the top level fit into five sequential sub-processes: capture feedback (explicit via thumbs + implicit via latency, abandonment, retries), triage incidents by root cause (model issue, retrieval issue, prompt issue, infra issue), enrich the dataset with the cases where the system failed plus the correct answer labelled by a human, decide the cadence (quarterly scheduled by default + incident-driven when a pattern crosses a threshold), and promote the candidate by taking it through Tune → Eval → Deploy with gates against the model in production. The tools the market has consolidated on in 2026: Langfuse for feedback collection in the UI, Argilla and Label Studio for human annotation of the enriched dataset, MLflow stages for promotion. The most lethal trap, and the most common, is the &lt;strong>open loop&lt;/strong>: having all the pieces but no structured channel connecting them, so the Retrain stage boils down to &amp;ldquo;we will retrain when we need to&amp;rdquo; and therefore never.&lt;/p>
&lt;h2 id="you-are-here-retrain-closes-the-cycle-back-to-data">You are here: Retrain (closes the cycle back to Data)&lt;/h2>
&lt;p>This post goes into the detail of stage 6 of the LLMOps pipeline. What follows takes apart the five Retrain sub-processes at complete top level, without descending into the internal mechanics of Tune (covered in the &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning post&lt;/a>) or into the implementation of the eval suites (covered in the &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals post&lt;/a>).&lt;/p>
&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: Retrain">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ffd24a;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(#rtm)}.cyc{stroke:#c66;stroke-width:2;fill:none;stroke-dasharray:4 2;marker-end:url(#rtm)}&lt;/style>
&lt;defs>&lt;marker id="rtm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#c66"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: RETRAIN · closes the cycle from Observe to Data&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box active"/>&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-master-analogy-the-hospital-mortality-committee">The master analogy: the hospital mortality committee&lt;/h2>
&lt;p>A serious hospital holds periodic &lt;strong>morbidity &amp;amp; mortality&lt;/strong> (M&amp;amp;M) meetings: the doctors review, without blame but without omitting anything, the cases where a patient died or had a serious complication. They look for root cause, identify patterns, adjust protocols, and leave a record. The committee does not meet when someone &amp;ldquo;remembers&amp;rdquo;; it is scheduled and mandatory. And when there is a catastrophic incident outside the cycle, an extraordinary M&amp;amp;M is convened within 48 h.&lt;/p>
&lt;p>The &lt;strong>Retrain&lt;/strong> stage is exactly that for an LLM system:&lt;/p>
&lt;ul>
&lt;li>The morbidity is the minor incidents: answers the user marked with a thumbs-down, sessions where they retried the same question three times, examples where the eval score dropped but not below the alert threshold.&lt;/li>
&lt;li>The mortality is the serious incidents: the system gave a dangerous answer, a key customer cancelled over a string of errors, the agent executed a tool it should not have.&lt;/li>
&lt;li>The periodic meetings are the quarterly &lt;strong>scheduled retrain&lt;/strong>: you look at the accumulated feedback, prioritise, and decide what goes into the enriched dataset for the next training run.&lt;/li>
&lt;li>The extraordinary M&amp;amp;Ms are the &lt;strong>incident-driven retrains&lt;/strong>: faced with a problematic pattern that crosses a threshold, a mini-cycle is fired off outside the cadence.&lt;/li>
&lt;/ul>
&lt;p>Without this discipline, incidents are anecdotes that get forgotten and the system does not learn.&lt;/p>
&lt;h2 id="sub-process-1--feedback-capture">Sub-process 1 — Feedback capture&lt;/h2>
&lt;p>The first link in the loop is observing what the system does badly. There are two families of feedback, and they complement each other.&lt;/p>
&lt;h3 id="explicit-feedback">Explicit feedback&lt;/h3>
&lt;p>The user tells you directly that the answer was bad. Mechanisms:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Thumbs up/down&lt;/strong> in the UI: the classic, low latency (1 click). Coverage: typically 1-5 % of traffic. Bias: users vote more when they are annoyed than when they are happy.&lt;/li>
&lt;li>&lt;strong>Annotation by power users&lt;/strong>: internal customers or experts who leave structured comments (&amp;ldquo;the answer is correct but the format does not follow our style guide&amp;rdquo;). Far lower coverage but high quality.&lt;/li>
&lt;li>&lt;strong>&amp;ldquo;What went wrong?&amp;rdquo; forms&lt;/strong> when the thumbs-down is clicked: predefined options (hallucination, format, tone, incomplete, off-topic) + optional free text. Enables automated triaging.&lt;/li>
&lt;li>&lt;strong>Re-editing&lt;/strong>: if the system writes a draft (an email, some code) and the user edits it before sending, that edit is rich feedback. The diff between what was generated and what was sent is an explicit signal of the failure.&lt;/li>
&lt;/ul>
&lt;p>All explicit feedback travels labelled with &lt;code>trace_id&lt;/code>, &lt;code>prompt_version&lt;/code>, &lt;code>model&lt;/code>, &lt;code>user_id&lt;/code> (anonymised where required), &lt;code>timestamp&lt;/code>, and enters the feedback store. Langfuse, Phoenix and LangSmith have built-in UI for this; what matters is that &lt;strong>every thumbs-down materialises as a row in a table&lt;/strong>, not as an event that gets lost.&lt;/p>
&lt;h3 id="implicit-feedback">Implicit feedback&lt;/h3>
&lt;p>The user tells you nothing but their behaviour gives the problem away. Typical signals:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Anomalous latency&lt;/strong>: the system&amp;rsquo;s TTFT was 8 s when the average is 800 ms. Indicates overload, heavy retrieval, an unexpectedly large prefill. Covered at top level in &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">ebpf+drift&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Abandonment rate&lt;/strong>: the user leaves the session before reading the full answer. If the ratio goes from 5 % to 15 % in one segment, something is wrong.&lt;/li>
&lt;li>&lt;strong>User retries&lt;/strong>: the user asks the same question (or a very similar one) 2-3 times. Indicates the first answer was no use to them.&lt;/li>
&lt;li>&lt;strong>Aborted sessions&lt;/strong>: the user closes the chat before the model finishes generating. In streaming, a high abort ratio is a strong indicator.&lt;/li>
&lt;li>&lt;strong>Workflow exit&lt;/strong>: in an agent, the user cancels the plan before execution. The agent&amp;rsquo;s trajectory did not convince them.&lt;/li>
&lt;li>&lt;strong>Statistical drift&lt;/strong> in the distribution of inputs or outputs (KS test, PSI, embedding-space shift). Covered at top level in &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Implicit signals are noisier but cover 100 % of traffic, not the 1-5 % of explicit feedback. Combining them with explicit feedback gives the full picture.&lt;/p>
&lt;h3 id="typical-storage-pattern">Typical storage pattern&lt;/h3>
&lt;p>All the feedback, explicit and implicit, ends up in a common table with a minimal schema:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">feedback_signals&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">signal_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">trace_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">request_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">signal_type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">VARCHAR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#39;thumbs&amp;#39;, &amp;#39;retry&amp;#39;, &amp;#39;abandon&amp;#39;, &amp;#39;drift&amp;#39;, ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">signal_value&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">JSONB&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- feedback payload (thumbs-down text, latency, etc.)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">prompt_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">VARCHAR&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">prompt_version&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">VARCHAR&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">user_segment&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">VARCHAR&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- tenant, plan, geo
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">occurred_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMPTZ&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">triaged&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BOOLEAN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FALSE&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">triage_label&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">VARCHAR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- filled in by sub-process 2
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Postgres is more than enough for reasonable volumes (millions of rows a month). Langfuse uses Postgres underneath. For high volumes you can divert to ClickHouse or BigQuery, but it is rarely worth the extra complexity.&lt;/p>
&lt;h2 id="sub-process-2--triage-by-root-cause">Sub-process 2 — Triage by root cause&lt;/h2>
&lt;p>Having feedback is not enough. You have to categorise each incident by its root cause before deciding what to do with it. Without triage, the enriched dataset is a junk drawer and the next retrain fixes nothing in particular.&lt;/p>
&lt;p>The four canonical categories:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Category&lt;/th>
&lt;th>Means&lt;/th>
&lt;th>Typical action&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Model issue&lt;/strong>&lt;/td>
&lt;td>The model answered badly to something that was within its theoretical capability.&lt;/td>
&lt;td>Candidate case for the enriched dataset for the next Tune.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Retrieval issue&lt;/strong>&lt;/td>
&lt;td>The RAG did not retrieve the right context. The model answered reasonably from poor context.&lt;/td>
&lt;td>Adjust reranker, chunking, indexing — the Data stage, not Tune.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Prompt issue&lt;/strong>&lt;/td>
&lt;td>The system prompt does not cover the case, or covers it badly.&lt;/td>
&lt;td>New version of the prompt (the cross-cutting stage of &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">prompt versioning&lt;/a>).&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Infra issue&lt;/strong>&lt;/td>
&lt;td>Latency, timeout, 5xx error, overload.&lt;/td>
&lt;td>Adjust capacity / autoscaler — the Deploy stage.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Triage can be done:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Manually&lt;/strong>: a human (typically: the MLE team / data scientist) reviews the feedback in the Langfuse / Phoenix / LangSmith UI, looks at the complete trace, labels it. Cost: 2-5 min per incident. Sustainable up to around 50-100 incidents/week per person.&lt;/li>
&lt;li>&lt;strong>Assisted by LLM-as-classifier&lt;/strong>: an LLM classifies the incident into one of the four categories with a structured prompt. 80-90 % coverage automated, the rest escalated to a human. State of the art in 2026: GPT-5, Claude 4, Llama 3 70B-instruct with a careful prompt give F1 &amp;gt; 0.85 against calibrated internal rubrics.&lt;/li>
&lt;li>&lt;strong>Heuristic rules for the obvious ones&lt;/strong>: a 5xx error is always infra; latency &amp;gt; 5σ is always infra; a thumbs-down over RAG with &lt;code>context_relevance &amp;lt; 0.3&lt;/code> is retrieval. Captures 30-50 % of the volume at zero cost.&lt;/li>
&lt;/ul>
&lt;p>The productive pattern is: &lt;strong>rules → LLM classifier → human&lt;/strong>, cascaded, escalating only what the previous level does not resolve with confidence.&lt;/p>
&lt;pre tabindex="0">&lt;code>New feedback
│
▼
[heuristic rules]
│
├── high confidence → automatic label
│
▼ (rest)
[LLM-as-classifier]
│
├── high confidence → suggested label
│
▼ (rest, or disagreement with the rules)
[human review]
│
└── final label → feedback_signals.triage_label
&lt;/code>&lt;/pre>&lt;h2 id="sub-process-3--dataset-enrichment">Sub-process 3 — Dataset enrichment&lt;/h2>
&lt;p>Once the incidents are triaged with a &lt;code>model issue&lt;/code> label, those cases are candidates to enter the enriched dataset that will feed the next Tune. But they do not go in as they are: you need &lt;strong>the correct answer labelled by a human&lt;/strong>.&lt;/p>
&lt;h3 id="how-an-enriched-case-is-built">How an enriched case is built&lt;/h3>
&lt;p>Each enriched case is a minimal tuple:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">case_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">enrich-2026-05-22-0142&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">source_trace_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">trace-xyz&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">prompt_input&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">system&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;You are a support assistant...&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;I cancelled my order on Tuesday but I can still see the charge&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">prompt_version_at_failure&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">customer_support_v3@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">model_at_failure&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-3-70b-instruct&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">failure_response&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Sorry for the inconvenience. The charge should be reversed within 5-7 business days.&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">human_corrected_response&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Sorry for the inconvenience. I have checked your account and I can see the refund was processed on Wednesday. It will show in your account within a further 24-48 h depending on your bank. Here is the refund ID: ABC123.&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">labeler&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;support_agent_M3&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">labeled_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2026-05-22T09:30:00Z&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">quality_score&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 1-5, eval by a second human before promoting to the dataset&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">notes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;The model gave a generic answer without checking the real state of the refund. It needs the account_lookup tool.&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What matters is that the enriched case has enough context to be reproduced: original prompt, prompt version, model, failed answer, correct answer. Without this, the case is a loose data point, useless for training.&lt;/p>
&lt;h3 id="annotation-tools">Annotation tools&lt;/h3>
&lt;p>Three dominant options in 2026:&lt;/p>
&lt;p>&lt;a href="https://argilla.io/">Argilla&lt;/a> (OSS, maintained by Hugging Face since 2024). Designed specifically for LLM datasets: annotation of (input, output) pairs, preference data (DPO/RLHF), instruction tuning. Python-friendly UI. Native integration with HuggingFace datasets and with MLflow.&lt;/p>
&lt;p>&lt;a href="https://labelstud.io/">Label Studio&lt;/a> (OSS from Heartex). More generalist, also works for LLM. Rich, configurable, multi-modal UI. Better when the team already uses it for other tasks.&lt;/p>
&lt;p>Langfuse built-in UI. Lets you annotate existing traces directly with thumbs + text + categorical labels. Useful for lightweight feedback; for building serious preference or instruction tuning datasets, Argilla and Label Studio are a better fit.&lt;/p>
&lt;p>Typical pattern: Langfuse for traffic feedback + Argilla to build the formal enriched dataset that goes into the Tune pipeline. The traces marked as candidates in Langfuse are exported periodically to Argilla, where a human produces the correct answer and validates quality.&lt;/p>
&lt;h3 id="quality-validation-before-promoting">Quality validation before promoting&lt;/h3>
&lt;p>Not every annotated case enters the dataset. Good discipline requires:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Double annotation&lt;/strong> on at least 10-20 % of the critical cases (two independent annotators; if they disagree, a third resolves it).&lt;/li>
&lt;li>&lt;strong>Quality score&lt;/strong> per case (1-5 or equivalent): only cases with a score ≥ 4 enter the dataset.&lt;/li>
&lt;li>&lt;strong>Dataset versioning&lt;/strong> with &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">DVC + lakeFS&lt;/a> or equivalent, just like the rest of the Data stage datasets.&lt;/li>
&lt;li>&lt;strong>Reserved holdout&lt;/strong>: a portion of the enriched dataset is set aside to evaluate the retrained adapter, without it entering training. If the dataset is enriched with cases where the model failed and the same dataset is used to evaluate, you are measuring memorisation, not learning.&lt;/li>
&lt;/ul>
&lt;h2 id="sub-process-4--cadences-scheduled-vs-incident-driven">Sub-process 4 — Cadences: scheduled vs incident-driven&lt;/h2>
&lt;p>Once enriched dataset accumulates, it remains to decide when the retrain is launched. There are two complementary cadences.&lt;/p>
&lt;h3 id="scheduled-retrain-quarterly-by-default">Scheduled retrain (quarterly by default)&lt;/h3>
&lt;p>A process fixed in the calendar. Every quarter, in a specific week, the team:&lt;/p>
&lt;ol>
&lt;li>Closes the accumulated feedback capture cycle.&lt;/li>
&lt;li>Counts the enriched cases available (typically tens to hundreds per quarter).&lt;/li>
&lt;li>Launches the fine-tuning pipeline with the aggregated dataset (golden dataset + the quarter&amp;rsquo;s enriched cases).&lt;/li>
&lt;li>Evaluates the candidate against the full suite + the enriched holdout.&lt;/li>
&lt;li>Promotes it if it passes the eval gates.&lt;/li>
&lt;/ol>
&lt;p>Advantages: predictable capacity planning, a closed budget, controlled risk, a team that is not burnt out. The default.&lt;/p>
&lt;h3 id="incident-driven-retrain">Incident-driven retrain&lt;/h3>
&lt;p>When a serious incident crosses a threshold, a mini-cycle fires off outside the cadence. Typical triggers:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Drift detected&lt;/strong> in the input/output distribution over threshold (KS p-value &amp;lt; 0.01, PSI &amp;gt; 0.25, embedding-space shift &amp;gt; 2σ).&lt;/li>
&lt;li>&lt;strong>A failing segment&lt;/strong>: a cluster of users or a type of question shows a 3× error rate over baseline for &amp;gt; 48 h.&lt;/li>
&lt;li>&lt;strong>A successful prompt injection or jailbreak attack&lt;/strong> that crosses the severity threshold (covered in &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails&lt;/a>).&lt;/li>
&lt;li>&lt;strong>External domain change&lt;/strong>: the customer changes policy, a new regulation comes out, and so on. The model trained against the old version stops being valid.&lt;/li>
&lt;/ul>
&lt;p>A typical mini-cycle: feedback from the last 7-14 days, a dataset focused on the problematic segment, fast fine-tuning on top of the existing adapter (not a full retrain), a segment-specific eval gate, canary deployment, promotion if it passes.&lt;/p>
&lt;p>Cost: around 3-7 days of team work depending on severity. &lt;strong>It is not optional for critical cases&lt;/strong>: if the failing segment is regulatory or reputational, the cost of not responding quickly is far higher than that of the mini-cycle.&lt;/p>
&lt;h3 id="anti-pattern-we-will-retrain-when-we-need-to">Anti-pattern: &amp;ldquo;we will retrain when we need to&amp;rdquo;&lt;/h3>
&lt;p>The most lethal phrase in LLMOps. Without explicit scheduling, the scheduled retrain never arrives; without explicit thresholds, the incident-driven one never fires either. The system accumulates silent debt until a catastrophic incident forces a retrain that is already late.&lt;/p>
&lt;p>The minimum discipline: a date in the calendar for the next scheduled run + 3-5 explicit incident-driven thresholds in writing. Without this, the Retrain stage is theatre.&lt;/p>
&lt;h2 id="sub-process-5--promotion-the-candidate-enters-production">Sub-process 5 — Promotion: the candidate enters production&lt;/h2>
&lt;p>Once the candidate adapter exists, it does not go straight into production. It goes through the same flow as any release: Tune → Eval → Deploy with gates.&lt;/p>
&lt;pre tabindex="0">&lt;code>Candidate adapter (from Tune)
│
▼
[full eval suite]
- historical golden dataset
- the quarter&amp;#39;s enriched holdout
- regression vs production
│
passes? → no → block + alert
│
yes
▼
[non-regression eval gate]
- make sure it does not degrade
segments that already worked
│
passes? → no → block + alert
│
yes
▼
[canary deployment]
- 5-10% of traffic to the new
adapter for 24-72 h
- online metrics vs production
│
metrics OK? → no → rollback
│
yes
▼
[full promotion]
- move the label in the model registry
- MLflow stages: Staging → Production
- The previous one goes to Archived (preserves
historical reproducibility)
&lt;/code>&lt;/pre>&lt;p>The registry tools:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>MLflow Model Registry stages&lt;/strong> (Staging, Production, Archived) is the canonical pattern. Promotion is an API call: &lt;code>mlflow.models.transition_stage(name, version, &amp;quot;Production&amp;quot;)&lt;/code>. Audited, revertible.&lt;/li>
&lt;li>&lt;strong>A private Hugging Face Hub&lt;/strong> with a repo per adapter is the &amp;ldquo;Git for models&amp;rdquo; equivalent: versioned by commit hash, branches for staging/production, deploy via PR.&lt;/li>
&lt;li>&lt;strong>vLLM multi-LoRA hot-swap&lt;/strong> (described in &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning&lt;/a>) loads the new adapter without restarting the server, so the physical promotion takes seconds.&lt;/li>
&lt;/ul>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>Retrain as a stage does not need big hardware. The calculation:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Feedback collection&lt;/strong>: a small table in Postgres. Trivial on any node.&lt;/li>
&lt;li>&lt;strong>Manual / assisted triage&lt;/strong>: the LLM-as-classifier runs on the same inference engine that serves production, during low-demand hours, at spot priority. Tens of thousands of incidents a month consume on the order of minutes of GPU per day.&lt;/li>
&lt;li>&lt;strong>Dataset enrichment&lt;/strong>: human annotation, no GPU cost. Negligible storage.&lt;/li>
&lt;li>&lt;strong>Tune (mini-cycle or quarterly)&lt;/strong>: here there is a cost. Fine-tuning a LoRA adapter over Llama 3 70B with a dataset of a few thousand examples costs on the order of 2-8 hours on a single H100. On 4 H100s with tensor parallel: 30-90 min. It fits comfortably into any low-demand overnight window.&lt;/li>
&lt;li>&lt;strong>Full eval suite&lt;/strong>: minutes on an engine with prefix caching active (covered in &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">pagedattention deep-dive&lt;/a>).&lt;/li>
&lt;li>&lt;strong>Canary deployment&lt;/strong>: zero additional cost, since the new adapter coexists in the same engine via multi-LoRA hot-swap.&lt;/li>
&lt;/ul>
&lt;p>For an &lt;strong>RTX 4090&lt;/strong> serving Llama 3 8B with a small team: a monthly or quarterly scheduled retrain overnight, an enriched dataset of 50-100 cases per cycle, annotation with self-hosted Argilla on the same node. Plenty.&lt;/p>
&lt;p>For a &lt;strong>4×H100 SXM cluster&lt;/strong> serving several tenants: an enriched dataset segregated by tenant (each with its own holdout and eval suite), a retrain pipeline orchestrated with Argo Workflows or equivalent, a centralised MLflow registry, multi-LoRA hot-swap per tenant.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;p>&lt;strong>The open loop.&lt;/strong> The system captures feedback, saves it to a table, and there it dies. Nobody triages, nobody enriches, nobody retrains. The deployed model ages silently. Fix: an explicit internal SLO (for example, &amp;ldquo;any feedback more than a week old without triage gets reported in standup&amp;rdquo;), with an assigned owner.&lt;/p>
&lt;p>&lt;strong>Human feedback that gets lost.&lt;/strong> Thumbs-downs with no structured capture (the event is logged but the reason is not), or the reason is logged but nobody indexes it for queries. Fix: an explicit schema like the one above, a weekly dashboard of &amp;ldquo;top thumbs-down reasons&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>Undefined cadence.&lt;/strong> &amp;ldquo;We will retrain when we need to&amp;rdquo; — never. Fix: a date in the calendar + 3-5 written thresholds.&lt;/p>
&lt;p>&lt;strong>No holdout test set.&lt;/strong> The enriched dataset is mixed with the golden dataset both to train AND to evaluate. The adapter looks like it improved because it &amp;ldquo;memorised&amp;rdquo; the enriched cases, but it generalises badly to new similar cases. Fix: a holdout reserved before training; the eval against the holdout is the metric that decides promotion.&lt;/p>
&lt;p>&lt;strong>Ad-hoc triage by one person.&lt;/strong> The senior data scientist triages when they can; while they are on holiday it piles up; they come back and give up because there are 400 incidents waiting. Fix: automate 70-80 % with an LLM-as-classifier, leave only the hard part to a human; rotate the &amp;ldquo;triage oncall&amp;rdquo; so one person does not get saturated.&lt;/p>
&lt;p>&lt;strong>Promotion without canary.&lt;/strong> The adapter passes the offline eval and is deployed to 100 % directly. A regression in production takes a while to be detected, until the online metrics show it, and by then the damage is done. Fix: a mandatory 5-10 % canary for 24-72 h.&lt;/p>
&lt;p>&lt;strong>No reproducibility of the original incident.&lt;/strong> The team goes to investigate why the model failed in the 22 May incident and discovers the prompt was different (it was changed two weeks ago), the model was too, and the logs did not keep the RAG context. Fix: strong traceability (covered in &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">prompt versioning&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>). Without reproducibility, retrain is guesswork.&lt;/p>
&lt;p>&lt;strong>The enriched dataset contaminates the Eval data.&lt;/strong> The team confuses &amp;ldquo;cases where it failed&amp;rdquo; (which go into the enriched training set) with &amp;ldquo;the golden regression dataset&amp;rdquo; (which has to stay stable to detect drift). Mixing them invalidates the eval. Fix: two different datasets, two different routes.&lt;/p>
&lt;h2 id="recommended-operational-pattern-the-quarterly-cycle-on-one-screen">Recommended operational pattern: the quarterly cycle on one screen&lt;/h2>
&lt;p>A serious team with Retrain properly implemented has this flow every 3 months:&lt;/p>
&lt;p>&lt;strong>Week 1 (cycle close)&lt;/strong>: new capture is frozen for the cycle, a snapshot is taken of the accumulated feedback. Automated report: how many thumbs-downs, how many incidents triaged, the distribution by category, top patterns.&lt;/p>
&lt;p>&lt;strong>Week 2 (triage and annotation)&lt;/strong>: the MLE team plus annotators process the untriaged &lt;code>model issue&lt;/code> cases. Human annotation in Argilla. Cross-validation on samples.&lt;/p>
&lt;p>&lt;strong>Week 3 (training and eval)&lt;/strong>: the pipeline is launched with dataset = golden + this_quarter&amp;rsquo;s_enriched - holdout. Fine-tuning the adapter overnight. Eval against the full suite + holdout. If it passes the gates, candidate &lt;code>v_new&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Week 4 (canary and promotion)&lt;/strong>: deploy the candidate as an alternative adapter in vLLM, route 5-10 % of traffic to the candidate for 48-72 h. Online metrics: latency, complaint rate, implicit eval in production. If everything is OK, full promotion; if not, rollback and analysis.&lt;/p>
&lt;p>&lt;strong>Week 5+ (next cycle)&lt;/strong>: the &lt;code>v_new&lt;/code> adapter is now &lt;code>production&lt;/code>. Feedback capture for the next quarter begins. The previous &lt;code>v_old&lt;/code> goes to &lt;code>Archived&lt;/code> but stays accessible for historical reproducibility.&lt;/p>
&lt;p>Quarterly, that cycle plus whatever incident-driven mini-cycles show up in between. Operational, predictable, auditable.&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>Online DPO and continuous on-policy learning&lt;/strong>: how the cycle shortens to hours or days (Fast-Slow Chasing, iterative RLOO). 2026 state of the art, still emerging in production.&lt;/li>
&lt;li>&lt;strong>Machine unlearning for GDPR&lt;/strong>: when a user exercises the right to be forgotten and their interactions formed part of the enriched dataset of an adapter in production. Negative LoRA, selective retraining.&lt;/li>
&lt;li>&lt;strong>Constitutional AI runtime&lt;/strong>: continuous alignment that replaces or complements periodic retraining.&lt;/li>
&lt;li>&lt;strong>Eval gates with metamorphic testing&lt;/strong>: evaluating robustness against input perturbations (typos, paraphrasing, language) as part of the promotion gate.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where Retrain is stage 6. This post goes into the detail of that box.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — the Tune mechanics that produce the new adapter of the cycle described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> — the eval suites that act as the gate in promotion sub-process 5.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow Prompts&lt;/a> — the cross-cutting component that guarantees reproducibility of the original incident when it comes to be triaged.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning for LLMOps: DVC, lakeFS and a reproducible golden dataset&lt;/a> — Retrain sub-process 3 enriches a dataset; this post goes into the detail of how to version it, its schema and its lineage.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF in local inference and statistical drift detection&lt;/a> — the drift signals that fire the incident-driven retrain.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — the safety / jailbreak incidents that also fire an incident-driven retrain.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its deep observability&lt;/a> — the OTel &lt;code>gen_ai.*&lt;/code> tracing that ties each piece of feedback to its complete trace, a necessary condition for triaging well.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO and SimPO&lt;/a> — the methods that consume the enriched dataset of sub-process 3 to produce the new adapter. KTO fits directly with binary 👍/👎 feedback; DPO with regenerate-as-rejected.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge: the exam marker&lt;/a> — the mechanism that automates the triaging and prioritisation of sub-process 1 (incident classification) without depending on complete human labelling.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving: the single translator with a thousand glossaries&lt;/a> — the last mile of the cycle: the adapter produced by retraining is served with SGMV/S-LoRA alongside the other N-1 concurrent adapters without touching the base or restarting the server.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001: the AI system&amp;rsquo;s operations manual&lt;/a> — the incident-driven retrain loop described here materialises clause 10 (improvement) of the AIMS: severity HIGH incidents → non-conformity → root cause → correction → documented verification of effectiveness.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">EU AI Act: the technical file article by article&lt;/a> — the incident-driven loop is the operational piece that closes Arts. 72 (post-market monitoring) and 73 (reporting of serious incidents within legal deadlines of 2-15 days); without it, the Regulation&amp;rsquo;s deadlines are unreachable.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> — the adapter produced by Retrain sub-process 4 enters the cluster through the progressive rollout; without a canary, an incident-driven correction can introduce a regression that passes the offline gates but not the real-traffic ones.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Argilla documentation, &lt;em>Building Datasets for LLM Fine-Tuning&lt;/em>: &lt;a href="https://argilla.io/docs">https://argilla.io/docs&lt;/a>.&lt;/li>
&lt;li>Label Studio documentation, &lt;em>LLM Annotation&lt;/em>: &lt;a href="https://labelstud.io/templates/llm">https://labelstud.io/templates/llm&lt;/a>.&lt;/li>
&lt;li>Langfuse documentation, &lt;em>User Feedback and Dataset Management&lt;/em>: &lt;a href="https://langfuse.com/docs/scores/user-feedback">https://langfuse.com/docs/scores/user-feedback&lt;/a>.&lt;/li>
&lt;li>MLflow Model Registry stages: &lt;a href="https://mlflow.org/docs/latest/model-registry.html">https://mlflow.org/docs/latest/model-registry.html&lt;/a>.&lt;/li>
&lt;li>Ethayarajh et al., &lt;em>KTO: Model Alignment as Prospect Theoretic Optimization&lt;/em> (2024) — reference for the feedback cycle as an alignment signal.&lt;/li>
&lt;li>Google Cloud, &lt;em>Continuous Training and MLOps for GenAI&lt;/em> (2025).&lt;/li>
&lt;li>DataRobot, &lt;em>MLOps Best Practices: Closing the Loop&lt;/em> (2025).&lt;/li>
&lt;li>Eugene Yan, &lt;em>Feedback Loops in LLM Systems&lt;/em> (blog, 2025).&lt;/li>
&lt;/ul></description></item><item><title>Prompt versioning: the contract that stops a five-word change sinking your system</title><link>https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/</link><pubDate>Fri, 22 May 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>In a classic software system, the most dangerous line a team can change is a SQL migration. In an LLM system, it is a line of prompt. The prompt determines the output as much as the model or more, it does not show up in unit tests, it does not appear in the logs by default, and if it is changed without leaving a trail there is no way to know which version generated which response. &lt;strong>Prompt versioning is the discipline that turns the prompt into a first-class artefact&lt;/strong>: with a unique identifier, history, deployment labels, an associated eval suite, and per-request traceability. The field has settled on three primitives (immutable version, mutable label, read cache) and two dominant tools (Langfuse OSS with a built-in UI, MLflow Prompts integrated into the registry since MLflow 3.10). This article covers the pattern at first level: why it matters, how it is implemented, which tool to choose, and how it fits with Eval, Deploy and Observe.&lt;/p>
&lt;h2 id="you-are-here-cross-cutting-touches-data-tune-eval-deploy-and-observe">You are here: cross-cutting (touches Data, Tune, Eval, Deploy and Observe)&lt;/h2>
&lt;p>Prompt versioning does not live in one stage, it &lt;strong>cuts across five&lt;/strong>. It appears as a cross-cutting component in the master map of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a> precisely for that reason: the prompt version is necessary metadata at every stage, not the responsibility of a single one.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 130" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: cross-cutting prompt versioning">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.cross{fill:#ffe9d6;stroke-width:3;stroke:#c66;rx:6}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#444}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#pvm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#pvm)}&lt;/style>
&lt;defs>&lt;marker id="pvm" 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: CROSS-CUTTING · prompt versioning cuts across every active stage&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;rect x="30" y="95" width="735" height="25" class="cross"/>
&lt;text x="397" y="112" text-anchor="middle" class="sm">Prompt registry (Langfuse / MLflow Prompts) · versioning · labels · cache · trace per request&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-master-analogy-the-prompt-is-an-invisible-sql-migration">The master analogy: the prompt is an invisible SQL migration&lt;/h2>
&lt;p>A serious backend team would never accept someone modifying a column directly in production without going through a versioned migration. Even if the change &amp;ldquo;works&amp;rdquo; at the time, without a migration there is no way to:&lt;/p>
&lt;ul>
&lt;li>Reproduce the previous state if something fails.&lt;/li>
&lt;li>Know who applied the change and when.&lt;/li>
&lt;li>Apply the same change in staging before prod.&lt;/li>
&lt;li>Test the new version against an automated suite before promoting.&lt;/li>
&lt;li>Know, two months later, why the table has the shape it has.&lt;/li>
&lt;/ul>
&lt;p>The LLM prompt occupies exactly that position in an inference system. Changing &lt;code>&amp;quot;You are a helpful assistant.&amp;quot;&lt;/code> to &lt;code>&amp;quot;You are a helpful, concise assistant. Answer in fewer than 3 sentences.&amp;quot;&lt;/code> can:&lt;/p>
&lt;ul>
&lt;li>Cut the average cost per response by 30 % (the responses are shorter).&lt;/li>
&lt;li>Or degrade quality in a segment where concision breaks necessary nuance.&lt;/li>
&lt;li>Or change the distribution of tools the agent decides to invoke.&lt;/li>
&lt;li>Or alter the behaviour of the downstream LLM judge that assumes a certain length.&lt;/li>
&lt;/ul>
&lt;p>And most importantly: if the change is made by &lt;strong>editing a constant in the app code and deploying&lt;/strong>, when two weeks later someone asks &lt;em>&amp;ldquo;why did the complaint rate go up in the finance segment?&amp;rdquo;&lt;/em>, &lt;strong>there is no way to know which prompt was being served at each moment&lt;/strong>. The logs store the response and, with luck, the model invoked; the prompt is rarely stored explicitly.&lt;/p>
&lt;p>Prompt versioning solves the same problem Flyway/Liquibase/Alembic solved for SQL: turning an invisible change into an auditable artefact.&lt;/p>
&lt;h2 id="the-three-primitives-of-the-pattern">The three primitives of the pattern&lt;/h2>
&lt;p>Whatever the tool, the systems that work in 2026 share three operational primitives worth fixing before looking at products.&lt;/p>
&lt;h3 id="1-immutable-version">1. Immutable version&lt;/h3>
&lt;p>Every time the prompt content changes (template, system message, available variables, recommended model parameters such as temperature), &lt;strong>a new version&lt;/strong> is generated with a unique identifier. The version is &lt;strong>immutable&lt;/strong>: once created, it is not overwritten; if something needs changing, v+1 is created.&lt;/p>
&lt;pre tabindex="0">&lt;code>prompt_id: customer_support_v3
versions:
v1 (2026-03-12): &amp;#34;Eres un asistente de soporte...&amp;#34;
v2 (2026-04-08): &amp;#34;Eres un asistente de soporte... formato JSON...&amp;#34;
v3 (2026-05-21): &amp;#34;Eres un asistente de soporte... formato JSON... 3 frases máx...&amp;#34;
&lt;/code>&lt;/pre>&lt;p>Immutability is what allows a trace from two months ago to be reproduced: if the trace says &amp;ldquo;&lt;code>customer_support_v3@v2&lt;/code> was served&amp;rdquo;, version v2 exists &lt;strong>literally&lt;/strong> and can be reloaded.&lt;/p>
&lt;h3 id="2-mutable-label-deployment-alias">2. Mutable label (deployment alias)&lt;/h3>
&lt;p>Versions are immutable, but &lt;strong>which version is in production changes&lt;/strong>. That decision is materialised in &lt;strong>labels&lt;/strong>: pointers with a semantic name (&lt;code>production&lt;/code>, &lt;code>staging&lt;/code>, &lt;code>canary&lt;/code>) that point at a specific version and can be re-pointed.&lt;/p>
&lt;pre tabindex="0">&lt;code>prompt_id: customer_support_v3
labels:
production → v2 (servida al 100% del tráfico)
canary → v3 (servida al 5% del tráfico via gateway)
staging → v3
&lt;/code>&lt;/pre>&lt;p>Promoting a version is &lt;strong>moving a label&lt;/strong>, not editing the prompt. Rollback is &lt;strong>moving the label back&lt;/strong>, not copying text. The operation reduces to an atomic mutation of a &lt;code>(label, version)&lt;/code> tuple.&lt;/p>
&lt;h3 id="3-read-cache">3. Read cache&lt;/h3>
&lt;p>The prompt is read on &lt;strong>every request to the model&lt;/strong>. If every read calls the prompt registry service, you add latency and a dependency. The standard solution is a &lt;strong>local cache&lt;/strong> in the client (TTL on the order of minutes) that invalidates when the label changes or when the TTL expires.&lt;/p>
&lt;p>Langfuse implements a native client cache with configurable TTL and lazy invalidation; MLflow Prompts leaves the responsibility to the client or to a gateway layer. In both cases, in production the client serves the prompt from memory with negligible overhead (&amp;lt;1 ms), and only goes to the registry when it refreshes.&lt;/p>
&lt;pre tabindex="0">&lt;code>┌──────────────────┐
│ Client (app) │
│ - local cache TTL=60s
│ - lookup label &amp;#34;production&amp;#34;
│ - gets template
│ - renders variables
│ - sends to LLM
└─────────┬────────┘
│ (when the TTL expires or a change event arrives)
▼
┌──────────────────┐
│ Prompt registry │
│ - Langfuse / MLflow
│ - GET label=&amp;#34;production&amp;#34;
│ - response: version_id + template
└──────────────────┘
&lt;/code>&lt;/pre>&lt;p>With these three primitives, &lt;strong>any reasonable tool is equivalent&lt;/strong> in the essentials. What distinguishes one from another is UI, integrations, RBAC, eval integration, and so on.&lt;/p>
&lt;h2 id="the-two-dominant-tools-in-2026">The two dominant tools in 2026&lt;/h2>
&lt;p>The field has converged on two main options. Any serious production deployment uses one of the two (sometimes both, for different teams).&lt;/p>
&lt;h3 id="langfuse-oss-prompt-management-ui-built-in">Langfuse (OSS, prompt-management UI built in)&lt;/h3>
&lt;p>Langfuse is the &lt;strong>prompt-first&lt;/strong> system: it was born for tracing and observability, and prompt management is one of its core layers. Key features for versioning:&lt;/p>
&lt;ul>
&lt;li>Built-in UI to create, edit and version prompts. Versions are generated automatically on save; the history is visible and diffable.&lt;/li>
&lt;li>Arbitrary labels beyond the usual ones (&lt;code>production&lt;/code>, &lt;code>latest&lt;/code>). You can define &lt;code>eu-prod&lt;/code>, &lt;code>internal-only&lt;/code>, &lt;code>customer-a&lt;/code> for fine-grained routing.&lt;/li>
&lt;li>Native client cache in the official SDKs (Python, JS), with configurable TTL, event-driven invalidation and fallback to the last-known-good if the registry is down.&lt;/li>
&lt;li>Native tracing integration: when you record a call to the LLM, Langfuse automatically associates the &lt;code>prompt_id@version&lt;/code> that served it. In the UI you see: this trace, this span, this prompt version X.&lt;/li>
&lt;li>Eval integration: Langfuse lets you register eval suites that fire when a new prompt version is created. The results stay linked to the &lt;code>prompt_id@version&lt;/code> and are the natural gating for promoting &lt;code>staging → production&lt;/code>.&lt;/li>
&lt;li>Self-hosted or cloud: the core is OSS (MIT), runs on Docker compose or Helm; the cloud version adds SLA, SSO and support.&lt;/li>
&lt;/ul>
&lt;p>When Langfuse suits:&lt;/p>
&lt;ul>
&lt;li>Teams that want a rich UI so product/PM/analyst can manage prompts without touching code.&lt;/li>
&lt;li>OSS-first deployments where control of the runtime and of persistence is a requirement (on-premise, ENS).&lt;/li>
&lt;li>When LLM observability is already in Langfuse: prompt management is marginal in setup.&lt;/li>
&lt;/ul>
&lt;h3 id="mlflow-prompts-included-in-mlflow-310-march-2026">MLflow Prompts (included in MLflow 3.10, March 2026)&lt;/h3>
&lt;p>MLflow Prompts is the classic MLOps ecosystem&amp;rsquo;s answer for LLMs. Features:&lt;/p>
&lt;ul>
&lt;li>Integrated into the MLflow Model Registry: prompts are first-class registry artefacts, with the same stage semantics (&lt;code>Staging&lt;/code>, &lt;code>Production&lt;/code>, &lt;code>Archived&lt;/code>) MLOps teams already know.&lt;/li>
&lt;li>API consistent with the rest of MLflow: &lt;code>mlflow.register_prompt()&lt;/code>, &lt;code>mlflow.load_prompt(name, stage=&amp;quot;Production&amp;quot;)&lt;/code>. The learning curve for teams already using MLflow for models is nil.&lt;/li>
&lt;li>Automatic versioning with a numeric &lt;code>version_id&lt;/code> (1, 2, 3, &amp;hellip;) and optional comments on promotion.&lt;/li>
&lt;li>No built-in UI dedicated to prompts (the MLflow UI does the job, but it is designed for models; the flow is less polished than in Langfuse).&lt;/li>
&lt;li>No native GenAI-aware tracing (MLflow Tracing in the 3.10 GenAI dashboard provides it, but the trace↔prompt integration is more manual than in Langfuse).&lt;/li>
&lt;li>Compatible with any model registry backend MLflow supports (filesystem, Postgres, MySQL, S3, GCS, Azure Blob).&lt;/li>
&lt;/ul>
&lt;p>When MLflow Prompts suits:&lt;/p>
&lt;ul>
&lt;li>Teams already operating MLflow for classic ML who want to extend the same discipline to LLMs without adding vendors.&lt;/li>
&lt;li>Deployments where the centre of gravity is the model registry and the prompt is one more artefact.&lt;/li>
&lt;li>CI/CD pipelines that already speak MLflow (CLI, REST API).&lt;/li>
&lt;/ul>
&lt;h3 id="comparison">Comparison&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Feature&lt;/th>
&lt;th>Langfuse&lt;/th>
&lt;th>MLflow Prompts&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Core licence&lt;/td>
&lt;td>MIT (OSS)&lt;/td>
&lt;td>Apache 2.0 (OSS)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Prompt-first UI&lt;/td>
&lt;td>✅&lt;/td>
&lt;td>⚠️ via Model Registry&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Immutable versioning&lt;/td>
&lt;td>✅&lt;/td>
&lt;td>✅&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mutable labels&lt;/td>
&lt;td>✅ (arbitrary)&lt;/td>
&lt;td>✅ (Staging/Production/Archived)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Native client cache&lt;/td>
&lt;td>✅&lt;/td>
&lt;td>❌ (DIY)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Integrated tracing&lt;/td>
&lt;td>✅ native&lt;/td>
&lt;td>⚠️ via MLflow Tracing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Eval gating on promotion&lt;/td>
&lt;td>✅&lt;/td>
&lt;td>⚠️ DIY with MLflow Recipes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Easy self-hosting&lt;/td>
&lt;td>✅ Docker/Helm&lt;/td>
&lt;td>✅ standard MLflow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Curve if you come from MLOps&lt;/td>
&lt;td>medium&lt;/td>
&lt;td>nil&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Curve if you come from DevOps&lt;/td>
&lt;td>nil&lt;/td>
&lt;td>medium&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>In May 2026, the most widespread hybrid pattern is to use MLflow for the models+adapters registry and Langfuse for prompts+tracing, connected by &lt;code>trace_id&lt;/code> and &lt;code>prompt_id&lt;/code> travelling in the OpenTelemetry span attributes. Covered in &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>.&lt;/p>
&lt;h2 id="minimum-schema-of-a-versioned-prompt">Minimum schema of a versioned prompt&lt;/h2>
&lt;p>Whatever the tool, what the registry stores in each version has a reasonable minimum schema:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># prompt_id: customer_support_v3, version: 3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">system&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Eres un asistente de soporte de {{company_name}}.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Responde en español neutral, máximo 3 frases.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Formato de respuesta: JSON {&amp;#34;answer&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;needs_human&amp;#34;: bool}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Pregunta del cliente: {{user_message}}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Contexto del ticket: {{ticket_context}}&lt;/span>&lt;span 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">variables&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">required&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">company_name, user_message, ticket_context]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">defaults&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">recommended_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="s2">&amp;#34;llama-3-70b-instruct&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">temperature&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">300&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">response_format&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;json_object&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">author&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;mlops@empresa.com&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">created_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2026-05-21T14:23:00Z&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">commit_message&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Añade límite de 3 frases tras feedback ticket #1842&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">eval_suite&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;customer_support_v3_evals&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">related_traces&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;trace_id_x&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;trace_id_y&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is &lt;strong>the minimum contract&lt;/strong>. What sets a serious deployment apart:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>variables.required&lt;/code>&lt;/strong> is validated in the client before sending to the model. A missing variable blows up at client time, not in a confusing model response.&lt;/li>
&lt;li>&lt;strong>&lt;code>recommended_params.model&lt;/code>&lt;/strong> ties the prompt version to a model. Changing model opens a debate (does the new version work with Llama 3 70B and with GPT-4o?). If it is not tied, the model is one more variable that wrecks reproducibility.&lt;/li>
&lt;li>&lt;strong>&lt;code>metadata.eval_suite&lt;/code>&lt;/strong> is what the eval suites hook into: on creating v3, MLflow/Langfuse fires &lt;code>customer_support_v3_evals&lt;/code> automatically.&lt;/li>
&lt;/ul>
&lt;h2 id="integration-with-eval-gates-governed-promotion">Integration with eval gates: governed promotion&lt;/h2>
&lt;p>The real value of prompt versioning appears when it is integrated with eval. The canonical pattern:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Developer edits the prompt in the UI&lt;/strong> (Langfuse) or via the &lt;strong>API&lt;/strong> (MLflow). &lt;code>v4&lt;/code> is created.&lt;/li>
&lt;li>&lt;strong>Automatic trigger&lt;/strong>: the &lt;code>prompt_created&lt;/code> event fires the associated eval suite (the &lt;code>eval_suite&lt;/code> in the metadata).&lt;/li>
&lt;li>&lt;strong>The suite runs&lt;/strong> against the golden dataset (questions+answers labelled by a human). Covered at first level in &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">the evals post&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Results are attached to the version&lt;/strong>: &lt;code>v4&lt;/code> now has &lt;code>eval_score: 0.84, regression_vs_v3: -0.03&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Promotion gate&lt;/strong>: if &lt;code>eval_score &amp;gt;= threshold&lt;/code> and &lt;code>regression &amp;lt; tolerance&lt;/code>, the &lt;code>staging&lt;/code> label moves to &lt;code>v4&lt;/code> automatically. If not, it alerts the developer.&lt;/li>
&lt;li>&lt;strong>Manual promotion to &lt;code>production&lt;/code>&lt;/strong>: with the eval passed, someone with permission moves &lt;code>production&lt;/code> from &lt;code>v3&lt;/code> to &lt;code>v4&lt;/code>. Atomic, auditable, reversible.&lt;/li>
&lt;/ol>
&lt;pre tabindex="0">&lt;code>Developer edits prompt → v4 created
│
▼
[eval suite trigger]
│
▼
Golden dataset 200 examples
│
▼
score = 0.84 (vs 0.87 for v3)
│
├── If it passes the threshold → label staging → v4
│ └── Manual promotion to production after review
└── If it does not pass → block + alert the developer
&lt;/code>&lt;/pre>&lt;p>This flow turns the prompt change from &amp;ldquo;someone touched the code and we prayed&amp;rdquo; into &amp;ldquo;a prompt change is a PR that passes CI&amp;rdquo;. It is the same discipline classic MLOps applied to models.&lt;/p>
&lt;h2 id="per-request-traceability-which-version-served-each-response">Per-request traceability: which version served each response&lt;/h2>
&lt;p>The last piece is &lt;strong>operational traceability&lt;/strong>: given a model response in production, which prompt version generated it?&lt;/p>
&lt;p>The pattern is to propagate the version as a &lt;strong>span attribute&lt;/strong> in OpenTelemetry, following the &lt;code>gen_ai.*&lt;/code> semantic conventions we covered in &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>:&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"># In the client (common pseudo-code)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">prompt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">registry&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;customer_support_v3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">label&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># v3 → v_id=14&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">tracer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">start_as_current_span&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;llm_call&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">span&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_attribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;gen_ai.prompt.id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;customer_support_v3&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_attribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;gen_ai.prompt.version&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;14&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_attribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;gen_ai.prompt.label&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_attribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;gen_ai.request.model&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">prompt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">params&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&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">llm&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">prompt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">render&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">user_message&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">msg&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">params&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">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_attribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;gen_ai.usage.input_tokens&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">response&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">usage&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_attribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;gen_ai.usage.output_tokens&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">response&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">usage&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">output&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In any trace (Langfuse, Phoenix, Jaeger, Honeycomb) you can see which exact version served that response. In an incident (&amp;ldquo;customer X received this on 22 May&amp;rdquo;) you reproduce &lt;strong>literally&lt;/strong> the version and the model that generated the output.&lt;/p>
&lt;p>Without that traceability, the incident stays an anecdote; with it, it is debuggable.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>Prompt versioning is a &lt;strong>computationally light&lt;/strong> layer compared with the inference engine or the fine-tuning pipeline. Its requirements:&lt;/p>
&lt;ul>
&lt;li>Storage: the prompt registry typically weighs megabytes (hundreds to thousands of prompts with their versions). Postgres with a &lt;code>prompts(id, version, template, params jsonb, metadata jsonb, created_at)&lt;/code> schema is more than enough. Langfuse uses Postgres by default; MLflow uses it for metadata (the blobs go to object storage or the filesystem).&lt;/li>
&lt;li>Registry compute: a small instance (1-2 vCPU, 2 GB RAM) serves tens of thousands of reads per minute if the client cache is enabled. Without a cache, it scales linearly with QPS but is still trivial.&lt;/li>
&lt;li>Triggered eval compute: here there is a real cost. Every time a new version is created, the eval suite runs. If the suite does LLM-as-judge over 200 examples and each eval costs 4 K tokens, one promotion costs on the order of 1 M tokens, minutes on a decent cluster, seconds if the suite already has its hot prefix cache.&lt;/li>
&lt;/ul>
&lt;p>For an &lt;strong>RTX 4090&lt;/strong> serving Llama 3 8B with a self-hosted prompt registry (Langfuse or MLflow): the registry runs on the same node in a sidecar container, the local app caches in RAM, the eval triggers run against the same inference engine at low priority. Full setup in a morning.&lt;/p>
&lt;p>For a &lt;strong>4×H100 SXM cluster&lt;/strong> serving a large model to several tenants: registry in a dedicated K8s pod with replicated Postgres, eval suites running in pods with the &lt;code>spot&lt;/code> priority class (covered in &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">the cluster as a platform&lt;/a>), OTel tracing propagating &lt;code>prompt_id+version&lt;/code> to a central Langfuse.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>Prompts hardcoded in the app code.&lt;/strong> The most common antipattern. The prompt lives in a &lt;code>prompts.py&lt;/code> file or &lt;code>templates/customer.txt&lt;/code> that gets deployed with the app. There is no real versioning (git history is not a substitute: it does not tie commit ↔ production trace operationally). Migrating to a registry is 1-2 sprints of work; it is worth every hour.&lt;/p>
&lt;p>&lt;strong>Badly calibrated cache.&lt;/strong> A TTL of hours with a mutable label means a rollback takes time to propagate. A TTL of seconds overloads the registry. The reasonable default is &lt;strong>60-300 seconds&lt;/strong> with event-driven invalidation (the registry emits a message to Kafka/Redis when a label changes, and clients invalidate immediately).&lt;/p>
&lt;p>&lt;strong>Unvalidated variables.&lt;/strong> The template uses &lt;code>{{user_name}}&lt;/code> but the app passes &lt;code>{{username}}&lt;/code>. The render produces a prompt with a literal &lt;code>{{user_name}}&lt;/code>. The model answers something bizarre and nobody knows why. Validating &lt;strong>required variables in the client&lt;/strong> before sending to the model is the minimum discipline.&lt;/p>
&lt;p>&lt;strong>Prompts inside chains evaluated at runtime.&lt;/strong> If your stack uses LangChain, LlamaIndex or similar with chains that compose prompts at runtime, the &lt;strong>final&lt;/strong> prompt the model sees may not be in the registry because it was composed from several fragments. Solutions: either register the chains as artefacts, or log the effective composed prompt in every trace.&lt;/p>
&lt;p>&lt;strong>Eval suite not hooked to the &lt;code>prompt_id&lt;/code>.&lt;/strong> Without that link, a prompt change gets promoted without passing evals. The integration has to be &lt;strong>a field in the prompt metadata&lt;/strong> (&lt;code>eval_suite: ...&lt;/code>) that the system reads and fires automatically. If it depends on the developer &amp;ldquo;remembering&amp;rdquo;, the pattern will fail.&lt;/p>
&lt;p>&lt;strong>Non-existent RBAC roles.&lt;/strong> Anyone with access to the UI can move &lt;code>production&lt;/code> to any version. Without separating &lt;code>editor&lt;/code> (creates versions) from &lt;code>releaser&lt;/code> (moves production labels), a junior developer can break production with an accidental promotion. Langfuse Enterprise has granular RBAC; MLflow has it via the server backend with per-experiment/registry permissions.&lt;/p>
&lt;p>&lt;strong>Prompts with sensitive data inline.&lt;/strong> The prompt template includes few-shot examples with real names, addresses, customer IDs. The registry stores that indefinitely. Under GDPR, the right to erasure applies to the registry too. Good practice: &lt;strong>variables for sensitive data&lt;/strong>, not inline; periodic audit of the registry content.&lt;/p>
&lt;h2 id="recommended-operational-pattern-the-cycle-on-one-screen">Recommended operational pattern: the cycle on one screen&lt;/h2>
&lt;p>A serious team with prompt versioning properly set up has the following cycle, repeatable and cheap:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Developer opens a PR in the repo&lt;/strong>: changes the app code if necessary, but &lt;strong>does not touch the prompt&lt;/strong> there.&lt;/li>
&lt;li>&lt;strong>Edits the prompt in the Langfuse/MLflow UI&lt;/strong>: creates &lt;code>v_new&lt;/code>. Adds a commit message (&amp;ldquo;adds a 3-sentence limit after feedback on ticket #1842&amp;rdquo;).&lt;/li>
&lt;li>&lt;strong>The eval suite fires automatically&lt;/strong>: it runs against the golden dataset, results appear in the UI within minutes.&lt;/li>
&lt;li>&lt;strong>If it passes eval&lt;/strong>: the &lt;code>staging&lt;/code> label moves to &lt;code>v_new&lt;/code> automatically. The developer can test staging with controlled traffic.&lt;/li>
&lt;li>&lt;strong>Human review&lt;/strong> (1-2 people, optional depending on severity): approval.&lt;/li>
&lt;li>&lt;strong>Promotion to &lt;code>production&lt;/code>&lt;/strong>: move the label, atomic. The client caches for 60-300 s, then serves the new version.&lt;/li>
&lt;li>&lt;strong>Observe&lt;/strong>: in Langfuse/Phoenix, production metrics and eval scores are segmented by prompt version. If the score drops with &lt;code>v_new&lt;/code>, alert.&lt;/li>
&lt;li>&lt;strong>If there is a serious regression&lt;/strong>: rollback is &lt;strong>moving the label back&lt;/strong>. A 5-second operation.&lt;/li>
&lt;/ol>
&lt;p>Every step is audited, every decision leaves a trail, every rollback is an atomic operation. This is what separates a GenAI system from &amp;ldquo;demos that worked once&amp;rdquo; and makes it operable for years.&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>Automatic prompt optimisation&lt;/strong>: techniques such as DSPy, TextGrad, PromptBreeder that generate prompt candidates and optimise them against a measurable objective. The extension of versioning where the &amp;ldquo;developer&amp;rdquo; can be an optimiser.&lt;/li>
&lt;li>&lt;strong>Prompt injection and red teaming&lt;/strong>: integrating versioning with the adversarial evaluation flow. Partially covered in &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Different versions per tenant&lt;/strong>: when the same &lt;code>prompt_id&lt;/code> needs variants per customer (i18n, branding, domain). Fork + override pattern.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where prompt versioning appears as a cross-cutting component in the &amp;ldquo;all stages&amp;rdquo; band. This post goes into the detail of that component.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps specific to LLMs in 2026: the landscape&lt;/a> — the opening of the series with the tooling context and the structural differences from classic MLOps.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing that decides whether your LLM performs or only looks like it&lt;/a> — the eval suites that hook into &lt;code>prompt_id&lt;/code> for the promotion gate described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its deep observability&lt;/a> — the &lt;code>gen_ai.*&lt;/code> semantic conventions and the trace context propagation that carry &lt;code>prompt_id+version&lt;/code> across every span.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight and the new LLM tracing&lt;/a> — how tracing observes prompts at runtime, including prompts composed in chains.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — the Tune+Retrain cycle produces adapters whose system prompt coexists with the versioning described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge: the exam marker&lt;/a> — the judge&amp;rsquo;s prompt is versioned here too. A &amp;ldquo;minor&amp;rdquo; change in the rubric invalidates the κ calibration and, as a consequence, every gate that depends on the judge.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the full OTel pipeline (Collector → Langfuse + Tempo + Prometheus) over which &lt;code>gen_ai.prompt.id+version&lt;/code> travels as a span attribute. Here we cover prompt versioning; there, the substrate that transports it.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside: v3 architecture and the 10 backend knobs&lt;/a> — the prompt management layer described here lives in Postgres inside Langfuse&amp;rsquo;s six-service architecture; that post opens the box and explains how to operate it self-hosted.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — the system prompt is part of the perimeter to protect against leakage (OWASP LLM07). The versioning described here plus the GR output detectors are the two pieces that stop accidental prompt changes opening breaches in silence.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">Langfuse v4: what goes into a trace&lt;/a> — the version 4 data model and the five score types.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Langfuse documentation, &lt;em>Prompt Management&lt;/em>: &lt;a href="https://langfuse.com/docs/prompts/get-started">https://langfuse.com/docs/prompts/get-started&lt;/a>.&lt;/li>
&lt;li>MLflow 3.10 release notes, &lt;em>Prompts in Model Registry&lt;/em> (marzo 2026): &lt;a href="https://mlflow.org/releases/3.10">https://mlflow.org/releases/3.10&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, &lt;em>Semantic Conventions for Generative AI&lt;/em> (estables desde 1.36 de OTel): &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">https://opentelemetry.io/docs/specs/semconv/gen-ai/&lt;/a>.&lt;/li>
&lt;li>Google Cloud, &lt;em>Prompt Management: Best Practices for Production LLM Systems&lt;/em> (2025).&lt;/li>
&lt;li>Chip Huyen, &lt;em>Designing Machine Learning Systems&lt;/em> — capítulo sobre model registry y prompt-as-artifact (2ª edición, marzo 2026).&lt;/li>
&lt;li>Eugene Yan, &lt;em>Prompt Engineering as Software Engineering&lt;/em> (blog, 2025).&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><item><title>The six-stage LLMOps pipeline: global architecture and a deep dive into every component</title><link>https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/</link><pubDate>Thu, 21 May 2026 06:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The first two posts in the series established the &lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">LLMOps landscape&lt;/a> and went down into the detail of the &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">data pipeline with Kafka&lt;/a>. This post does the intermediate zoom: it draws &lt;strong>the complete map of the system&lt;/strong>, a global architecture of a modern LLMOps setup with all the pieces the field has settled on in 2026, and goes into depth on each of the &lt;strong>six canonical stages of the pipeline&lt;/strong>: &lt;strong>Data&lt;/strong>, &lt;strong>Tune&lt;/strong>, &lt;strong>Eval&lt;/strong>, &lt;strong>Deploy&lt;/strong>, &lt;strong>Observe&lt;/strong>, &lt;strong>Retrain&lt;/strong>. For each stage we give the &lt;strong>operational sub-tasks&lt;/strong>, the &lt;strong>dominant tools&lt;/strong>, the &lt;strong>design decisions&lt;/strong> that always show up, and the &lt;strong>specific traps&lt;/strong> that are seen repeatedly in production. And, most important operationally: every stage carries a &lt;strong>&amp;ldquo;you are here&amp;rdquo; mini-map&lt;/strong> over the cycle, which will be reused in any later post in the series to place the reader. The idea: that anyone reading a post about fine-tuning, about prompt versioning, about eval gates or about drift detection can look at the mini-map and know immediately which piece of the larger system they are thinking about that day.&lt;/p>
&lt;blockquote>
&lt;p>This is the &lt;strong>third post in the MLOps series specific to LLMs&lt;/strong>. Previous ones: &lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">Landscape 2026&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka&lt;/a>. Here we move from &amp;ldquo;the what&amp;rdquo; and &amp;ldquo;one piece&amp;rdquo; to &lt;strong>the whole map&lt;/strong>, with detail per stage.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-global-architecture-the-master-map">The global architecture: the master map&lt;/h2>
&lt;p>Before going down into each stage, let us fix the whole map. What follows is the reference drawing of a production LLMOps system in 2026, with every component the field has settled on in its place:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 580" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Global LLMOps architecture 2026">
&lt;style>.title{font:700 14px sans-serif;fill:#222}.stage-title{font:700 13px sans-serif;fill:#222}.lbl{font:11px sans-serif;fill:#333}.sm{font:10px sans-serif;fill:#555}.tiny{font:9px sans-serif;fill:#666}.stage{stroke:#444;stroke-width:1.5;rx:8}.data{fill:#ffe9d6}.tune{fill:#ffd6d6}.eval{fill:#d6eaff}.deploy{fill:#d9f5d6}.obs{fill:#e9d6f5}.retrain{fill:#fff5b0}.cross{fill:#f0f0f0;stroke:#888;stroke-dasharray:4 2;rx:6}.arr{stroke:#444;stroke-width:1.6;fill:none;marker-end:url(#ar)}.cycle{stroke:#888;stroke-width:1.4;fill:none;marker-end:url(#ar);stroke-dasharray:6 3}&lt;/style>
&lt;defs>&lt;marker id="ar" 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="#444"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="22" text-anchor="middle" class="title">Global LLMOps architecture 2026 — the six stages and the cross-cutting components&lt;/text>
&lt;rect x="20" y="50" width="240" height="170" class="stage data"/>
&lt;text x="140" y="72" text-anchor="middle" class="stage-title">1 · DATA&lt;/text>
&lt;text x="35" y="92" class="sm">• Sources: OLTP, APIs, logs, scraping&lt;/text>
&lt;text x="35" y="108" class="sm">• CDC: Debezium, Flink CDC&lt;/text>
&lt;text x="35" y="124" class="sm">• Transport: Kafka + Schema Registry&lt;/text>
&lt;text x="35" y="140" class="sm">• Stream proc: Flink SQL, RisingWave&lt;/text>
&lt;text x="35" y="156" class="sm">• Versioning: DVC + lakeFS&lt;/text>
&lt;text x="35" y="172" class="sm">• Tableflow → Iceberg/Delta&lt;/text>
&lt;text x="35" y="188" class="sm">• Vector stores: Milvus, Qdrant,&lt;/text>
&lt;text x="35" y="202" class="sm"> Weaviate, pgvector, LanceDB&lt;/text>
&lt;rect x="280" y="50" width="240" height="170" class="stage tune"/>
&lt;text x="400" y="72" text-anchor="middle" class="stage-title">2 · TUNE&lt;/text>
&lt;text x="295" y="92" class="sm">• Modes: fine-tune / RAG /&lt;/text>
&lt;text x="295" y="106" class="sm"> agent training&lt;/text>
&lt;text x="295" y="124" class="sm">• Frameworks: PEFT, Axolotl, TRL,&lt;/text>
&lt;text x="295" y="138" class="sm"> Unsloth, llama-factory&lt;/text>
&lt;text x="295" y="156" class="sm">• Techniques: LoRA, QLoRA, DPO, RLHF&lt;/text>
&lt;text x="295" y="172" class="sm">• Clusters: H100/B200 + NVLink&lt;/text>
&lt;text x="295" y="188" class="sm">• Experiment tracking: MLflow, W&amp;amp;B&lt;/text>
&lt;text x="295" y="202" class="sm">• Adapter registry: private HF Hub&lt;/text>
&lt;rect x="540" y="50" width="220" height="170" class="stage eval"/>
&lt;text x="650" y="72" text-anchor="middle" class="stage-title">3 · EVAL&lt;/text>
&lt;text x="555" y="92" class="sm">• CI frameworks: DeepEval,&lt;/text>
&lt;text x="555" y="106" class="sm"> Promptfoo, Ragas, OpenAI Evals&lt;/text>
&lt;text x="555" y="124" class="sm">• Platforms: Langfuse, LangSmith,&lt;/text>
&lt;text x="555" y="138" class="sm"> Phoenix, Braintrust&lt;/text>
&lt;text x="555" y="156" class="sm">• Judge LLM (G-Eval, Prometheus)&lt;/text>
&lt;text x="555" y="172" class="sm">• Versioned golden dataset&lt;/text>
&lt;text x="555" y="188" class="sm">• Eval gates in CI/CD&lt;/text>
&lt;text x="555" y="202" class="sm">• Calibration 85-90% vs human&lt;/text>
&lt;rect x="20" y="245" width="240" height="170" class="stage deploy"/>
&lt;text x="140" y="267" text-anchor="middle" class="stage-title">4 · DEPLOY&lt;/text>
&lt;text x="35" y="287" class="sm">• Model registry: MLflow, OME&lt;/text>
&lt;text x="35" y="303" class="sm">• Serving: vLLM, SGLang, TRT-LLM&lt;/text>
&lt;text x="35" y="319" class="sm">• K8s operators: vLLM Prod Stack,&lt;/text>
&lt;text x="35" y="333" class="sm"> KServe, OME, NVIDIA Dynamo, llm-d&lt;/text>
&lt;text x="35" y="349" class="sm">• Gateway / router: LiteLLM&lt;/text>
&lt;text x="35" y="365" class="sm">• Strategies: canary, blue-green,&lt;/text>
&lt;text x="35" y="379" class="sm"> shadow, A/B versioning&lt;/text>
&lt;text x="35" y="395" class="sm">• Autoscaling: KEDA + LLM metrics&lt;/text>
&lt;rect x="280" y="245" width="240" height="170" class="stage obs"/>
&lt;text x="400" y="267" text-anchor="middle" class="stage-title">5 · OBSERVE&lt;/text>
&lt;text x="295" y="287" class="sm">• Tracing: OpenLLMetry, Langfuse,&lt;/text>
&lt;text x="295" y="301" class="sm"> Phoenix, LangSmith&lt;/text>
&lt;text x="295" y="319" class="sm">• Metrics: Prometheus, Grafana&lt;/text>
&lt;text x="295" y="335" class="sm">• Guardrails: NeMo, Llama Guard 4,&lt;/text>
&lt;text x="295" y="349" class="sm"> LLM Guard, Lakera&lt;/text>
&lt;text x="295" y="367" class="sm">• eBPF: Hubble, Tetragon, AgentSight&lt;/text>
&lt;text x="295" y="383" class="sm">• MCP observability (OTel GenAI)&lt;/text>
&lt;text x="295" y="399" class="sm">• Drift: Evidently, NannyML, WhyLabs&lt;/text>
&lt;rect x="540" y="245" width="220" height="170" class="stage retrain"/>
&lt;text x="650" y="267" text-anchor="middle" class="stage-title">6 · RETRAIN&lt;/text>
&lt;text x="555" y="287" class="sm">• Explicit feedback (thumbs)&lt;/text>
&lt;text x="555" y="303" class="sm">• Implicit feedback (latency,&lt;/text>
&lt;text x="555" y="317" class="sm"> abandonment, retries)&lt;/text>
&lt;text x="555" y="335" class="sm">• Incident triaging&lt;/text>
&lt;text x="555" y="351" class="sm">• Dataset enrichment with cases&lt;/text>
&lt;text x="555" y="365" class="sm"> where the model failed&lt;/text>
&lt;text x="555" y="383" class="sm">• Cadence: quarterly or&lt;/text>
&lt;text x="555" y="397" class="sm"> incident-driven&lt;/text>
&lt;rect x="100" y="440" width="580" height="120" class="cross"/>
&lt;text x="390" y="462" text-anchor="middle" class="stage-title">Cross-cutting components (they run through every stage)&lt;/text>
&lt;text x="115" y="482" class="sm">• OpenTelemetry Collector (gen_ai.* and mcp.* semantic conventions)&lt;/text>
&lt;text x="115" y="498" class="sm">• Prompt versioning: Langfuse / MLflow Prompts (v1/v2/v3 versioning + labels + cache)&lt;/text>
&lt;text x="115" y="514" class="sm">• MCP servers + MCP Gateway (Traefik Hub, MintMCP) — tool-model interface&lt;/text>
&lt;text x="115" y="530" class="sm">• Model gateway: LiteLLM (100+ providers unified as one OpenAI-compatible API)&lt;/text>
&lt;text x="115" y="546" class="sm">• Schema Registry (Avro/Protobuf/JSON Schema) shared between data and serving&lt;/text>
&lt;path class="arr" d="M260,135 L280,135"/>
&lt;path class="arr" d="M520,135 L540,135"/>
&lt;path class="arr" d="M650,220 L650,245"/>
&lt;path class="arr" d="M540,330 L520,330"/>
&lt;path class="arr" d="M280,330 L260,330"/>
&lt;path class="arr" d="M140,415 L140,440"/>
&lt;path class="arr" d="M400,415 L400,440"/>
&lt;path class="arr" d="M650,415 L650,440"/>
&lt;path class="cycle" d="M650,330 C780,330 780,135 760,135 L760,135"/>
&lt;text x="745" y="245" class="sm" text-anchor="middle">cycle&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>What you see: the &lt;strong>six large boxes&lt;/strong> are the stages; the &lt;strong>solid arrows&lt;/strong> are the flow of the pipeline; the &lt;strong>dashed arrow&lt;/strong> that goes from &lt;strong>Retrain&lt;/strong> to &lt;strong>Data&lt;/strong> is the feedback cycle that turns LLMOps into a living process rather than a project that ends. The grey band at the foot holds the &lt;strong>cross-cutting components&lt;/strong> (observability, prompt versioning, MCP, gateway, schema) that run through every stage and connect to each one.&lt;/p>
&lt;p>Three quick readings of the map:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Horizontal, top&lt;/strong>: the happy path, &lt;strong>data → tune → eval&lt;/strong>. What happens while you prepare the model.&lt;/li>
&lt;li>&lt;strong>Horizontal, bottom&lt;/strong>: the service path, &lt;strong>deploy → observe → retrain&lt;/strong>. What happens once the model is alive.&lt;/li>
&lt;li>&lt;strong>Vertical&lt;/strong>: the connection between the two floors. Eval gateway feeds Deploy; Observe feeds Retrain; Retrain returns to Data.&lt;/li>
&lt;/ul>
&lt;p>From here on, every stage will include a &lt;strong>navigation mini-map&lt;/strong> (&amp;ldquo;you are here&amp;rdquo;) to place you in the complete cycle. Let us go through each one.&lt;/p>
&lt;h2 id="stage-1--data-ingestion-transport-versioning-indexing">Stage 1 — Data: ingestion, transport, versioning, indexing&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: Data">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;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(#mn)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mn)}&lt;/style>
&lt;defs>&lt;marker id="mn" 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: DATA · ingestion → transport → versioning → indexing&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box active"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h3 id="operational-sub-tasks">Operational sub-tasks&lt;/h3>
&lt;p>The Data stage is the most underrated and the one that blocks the most projects. Its sub-tasks:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ingestion&lt;/strong> from heterogeneous sources: OLTP databases (Postgres, MySQL), external APIs, file shares, scraping, SaaS systems, application logs, internal messaging.&lt;/li>
&lt;li>&lt;strong>Change capture&lt;/strong> (CDC) in streaming if the data is dynamic. Debezium over Kafka, Flink CDC, modern alternatives such as RisingWave that reads the WAL directly.&lt;/li>
&lt;li>&lt;strong>Transformation&lt;/strong> (cleansing, dedup, normalisation, PII sanitisation).&lt;/li>
&lt;li>&lt;strong>Schema management&lt;/strong>: schema registry, compatible evolution, backward/forward compatibility.&lt;/li>
&lt;li>&lt;strong>Versioning&lt;/strong> of training datasets and golden datasets: DVC + lakeFS (unified in November 2025). Covered in detail in the &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">dedicated data versioning post&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Indexing&lt;/strong> for RAG: chunking, embeddings, writing to vector stores. Covered in depth in the &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">Kafka post&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Materialisation&lt;/strong> into analytical tables: Tableflow → Iceberg/Delta, for BI consumption and low-latency queries.&lt;/li>
&lt;/ul>
&lt;h3 id="dominant-tools">Dominant tools&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Sub-task&lt;/th>
&lt;th>Tools 2026&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>CDC&lt;/td>
&lt;td>Debezium, Flink CDC, RisingWave&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Transport&lt;/td>
&lt;td>Kafka (Confluent Cloud, Redpanda, pure Apache)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Schema Registry&lt;/td>
&lt;td>Confluent Schema Registry, Apicurio&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Stream processing&lt;/td>
&lt;td>Apache Flink, RisingWave, Kafka Streams&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Data versioning&lt;/td>
&lt;td>DVC + lakeFS&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Vector stores&lt;/td>
&lt;td>Milvus, Qdrant, Weaviate, pgvector, LanceDB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Materialised tables&lt;/td>
&lt;td>Tableflow → Iceberg/Delta&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Batch ETL/ELT (where it applies)&lt;/td>
&lt;td>dbt + Snowflake/Databricks&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="design-decisions">Design decisions&lt;/h3>
&lt;p>The three decisions that always show up:&lt;/p>
&lt;p>&lt;strong>Batch vs streaming&lt;/strong>: the more dynamic the data, the more streaming. For static corpora (manuals that never change) a nightly batch is enough; for transactional data the agent needs to see minute by minute, streaming from day 1.&lt;/p>
&lt;p>&lt;strong>Embedding model&lt;/strong>: changing the embedding model invalidates every indexed vector. An architectural decision: pin the model and have an explicit migration plan (the dual-index pattern seen in the Kafka post).&lt;/p>
&lt;p>&lt;strong>Vector store&lt;/strong>: pgvector if you already run Postgres and you are below 10M vectors; Qdrant if you want mid-scale simplicity; Milvus if you need billions; Weaviate if you value native hybrid search.&lt;/p>
&lt;h3 id="traps">Traps&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Hardcoding connections to the source&lt;/strong> (with no abstraction): when the database changes (version, host, schema), you break the whole pipeline. An &lt;strong>adapter layer&lt;/strong> from day 1.&lt;/li>
&lt;li>&lt;strong>No schema registry&lt;/strong>: topics start breaking silently.&lt;/li>
&lt;li>&lt;strong>Full reindexing whenever something changes&lt;/strong>: it costs hours or days. Design the &lt;strong>dual-index pattern&lt;/strong> from the start.&lt;/li>
&lt;li>&lt;strong>Unsanitised PII&lt;/strong>: the RAG is serving sensitive data without meaning to. Anonymisation in the pipeline, not at consumption time.&lt;/li>
&lt;/ul>
&lt;h2 id="stage-2--tune-preparing-the-model-for-your-case">Stage 2 — Tune: preparing the model for your case&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: Tune">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff7777;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(#mn2)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mn2)}&lt;/style>
&lt;defs>&lt;marker id="mn2" 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: TUNE · fine-tuning / RAG-as-tuning / agent training&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 active"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h3 id="operational-sub-tasks-1">Operational sub-tasks&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Base model selection&lt;/strong>: Llama, Qwen, Mistral, Gemma, DeepSeek according to licence, size, quality in your domain.&lt;/li>
&lt;li>&lt;strong>Dataset preparation&lt;/strong>: train/val/test split, format (chat templates, JSONL), augmentation where it applies.&lt;/li>
&lt;li>&lt;strong>Adapter configuration&lt;/strong>: LoRA rank, target modules, alpha; QLoRA if you want to train on a consumer GPU; full fine-tune only if you have the budget.&lt;/li>
&lt;li>&lt;strong>Training loop&lt;/strong>: HuggingFace Transformers + PEFT + TRL as the canonical stack; Axolotl or llama-factory as convenience wrappers; Unsloth if you want 2-4× more speed on consumer GPUs.&lt;/li>
&lt;li>&lt;strong>Hyperparameter sweep&lt;/strong>: W&amp;amp;B Sweeps, Optuna, Ray Tune.&lt;/li>
&lt;li>&lt;strong>Checkpointing and resumability&lt;/strong>: save every N steps, resume from failure.&lt;/li>
&lt;li>&lt;strong>Promotion&lt;/strong>: the adapter is promoted to the registry after passing the next stage (Eval).&lt;/li>
&lt;/ul>
&lt;h3 id="the-three-modes-of-tune">The three modes of Tune&lt;/h3>
&lt;p>The detail of the table we saw in the &lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">landscape&lt;/a>:&lt;/p>
&lt;p>&lt;strong>Supervised fine-tuning (SFT)&lt;/strong> with LoRA/QLoRA. You collect (prompt, ideal-response) pairs and apply SFT with cross-entropy loss. The simplest option. The rule of thumb: &lt;strong>300-3,000&lt;/strong> well-curated examples are usually more useful than 50,000 noisy ones.&lt;/p>
&lt;p>&lt;strong>DPO (Direct Preference Optimization)&lt;/strong> and &lt;strong>RLAIF&lt;/strong>. Instead of &amp;ldquo;ideal-response&amp;rdquo;, you collect &lt;strong>(prompt, good_response, bad_response)&lt;/strong> triples and train the model to prefer the good one. More stable than classic RLHF, same objective. It is what most teams use when they go beyond SFT.&lt;/p>
&lt;p>&lt;strong>Agent training&lt;/strong> (RFT / Reinforcement Fine-Tuning, pure RLHF). For cases where the model needs to learn &lt;strong>multistep trajectories&lt;/strong>: when to choose tool A over B, when to ask for confirmation, how to decompose a large task. Far more expensive and complex. OpenAI&amp;rsquo;s work with RFT set the pattern in 2024-2025; in 2026 it is coming out of the experimental phase.&lt;/p>
&lt;p>&lt;strong>RAG as an alternative to Tune&lt;/strong>: although conceptually it is another stage (it lives in Data + Deploy), functionally it competes with fine-tuning for many cases. The 2026 verdict: &lt;strong>hybrid is the default&lt;/strong> (60% of deployments), fine-tune for behaviour plus RAG for volatile knowledge.&lt;/p>
&lt;h3 id="tools">Tools&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Aspect&lt;/th>
&lt;th>Tools 2026&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Base framework&lt;/td>
&lt;td>HuggingFace Transformers, PEFT, TRL&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Convenience wrappers&lt;/td>
&lt;td>Axolotl, llama-factory&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Consumer speed&lt;/td>
&lt;td>Unsloth (2-4× speedup on RTX GPUs)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Distributed training&lt;/td>
&lt;td>DeepSpeed, FSDP, NeMo Framework&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Experiment tracking&lt;/td>
&lt;td>MLflow, W&amp;amp;B, ClearML&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Adapter registry&lt;/td>
&lt;td>Private HuggingFace Hub, MLflow registry&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hyperparameter&lt;/td>
&lt;td>W&amp;amp;B Sweeps, Optuna, Ray Tune&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="traps-1">Traps&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Catastrophic forgetting&lt;/strong>: very aggressive SFT destroys the model&amp;rsquo;s general capabilities. Keep a small % of the original dataset or use regularisation.&lt;/li>
&lt;li>&lt;strong>Overfitting to the golden dataset&lt;/strong>: the model learns to memorise the eval set. Keep a &lt;strong>holdout test set&lt;/strong> that nobody on the team looks at until the final release.&lt;/li>
&lt;li>&lt;strong>Train/serve skew&lt;/strong>: prompts in training with a different format from production. The &lt;strong>same chat template&lt;/strong> in both.&lt;/li>
&lt;li>&lt;strong>LoRA rank too high&lt;/strong>: it looks like it improves metrics but it inflates the adapter with no real benefit. Start with &lt;code>r=8&lt;/code> or &lt;code>r=16&lt;/code>; raise it only if there is evidence.&lt;/li>
&lt;/ul>
&lt;h2 id="stage-3--eval-validating-before-promoting">Stage 3 — Eval: validating before promoting&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: Eval">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7aafff;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(#mn3)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mn3)}&lt;/style>
&lt;defs>&lt;marker id="mn3" 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: EVAL · CI gates + platform regression + human review&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 active"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h3 id="operational-sub-tasks-2">Operational sub-tasks&lt;/h3>
&lt;p>Covered in depth in &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a>. A structured summary for the pipeline:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Golden dataset curation&lt;/strong>: 100-500 examples as a minimum, actively maintained with incident cases.&lt;/li>
&lt;li>&lt;strong>Evaluators&lt;/strong>: heuristic (regex, length), semantic (embeddings), LLM-as-judge (G-Eval), human (golden labels).&lt;/li>
&lt;li>&lt;strong>Running in CI&lt;/strong>: block the merge if critical metrics drop by more than X%.&lt;/li>
&lt;li>&lt;strong>Running on the platform&lt;/strong>: over sampled production traffic, persist results, detect long-term regression.&lt;/li>
&lt;li>&lt;strong>Judge calibration&lt;/strong>: 85-90% agreement with humans before accepting the judge as productive.&lt;/li>
&lt;li>&lt;strong>Eval gates&lt;/strong>: explicit thresholds per metric (faithfulness &amp;gt; 0.85, relevancy &amp;gt; 0.80, and so on).&lt;/li>
&lt;/ul>
&lt;h3 id="tools-1">Tools&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>CI gates&lt;/strong>: DeepEval (Apache 2.0, pytest-style), Promptfoo (MIT, CLI), Ragas (RAG-specific), Inspect AI (safety/capability).&lt;/li>
&lt;li>&lt;strong>Platform&lt;/strong>: Langfuse (MIT, complete suite), LangSmith (LangChain), Phoenix (ELv2, OTel), Braintrust.&lt;/li>
&lt;li>&lt;strong>Judges&lt;/strong>: GPT-4 (expensive but the reference), Claude 3.5 Sonnet, Prometheus (OSS, 0.897 correlation), JudgeLM.&lt;/li>
&lt;/ul>
&lt;h3 id="traps-2">Traps&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Aged golden dataset&lt;/strong>: if it is not updated, it stops reflecting production.&lt;/li>
&lt;li>&lt;strong>Contaminated judge&lt;/strong>: the judge knows the dataset (it appeared in its training).&lt;/li>
&lt;li>&lt;strong>Insufficient sample size&lt;/strong>: fewer than 50 examples makes differences look like noise.&lt;/li>
&lt;li>&lt;strong>Runaway costs&lt;/strong>: G-Eval with GPT-4 over many cases costs thousands of USD a month.&lt;/li>
&lt;li>&lt;strong>Forgetting the segment&lt;/strong>: an average of 0.85 can hide a 0.55 in German.&lt;/li>
&lt;/ul>
&lt;h2 id="stage-4--deploy-putting-the-model-into-production">Stage 4 — Deploy: putting the model into production&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">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7adb7a;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(#mn4)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mn4)}&lt;/style>
&lt;defs>&lt;marker id="mn4" 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 · operators + serving + canary + autoscaling&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h3 id="operational-sub-tasks-3">Operational sub-tasks&lt;/h3>
&lt;p>Covered in depth in &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM operators on K8s&lt;/a>. A summary for the pipeline:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Runtime selection&lt;/strong>: vLLM (default), SGLang (agents with high prefix caching), TensorRT-LLM (pure latency), llama.cpp (edge).&lt;/li>
&lt;li>&lt;strong>Operator selection&lt;/strong>: vLLM Production Stack, KServe, OME (LMSYS), NVIDIA Dynamo, llm-d (CNCF).&lt;/li>
&lt;li>&lt;strong>Serving configuration&lt;/strong>: &lt;code>--tensor-parallel-size&lt;/code>, &lt;code>--kv-cache-dtype=fp8&lt;/code>, &lt;code>--enable-prefix-caching&lt;/code>, &lt;code>--enable-chunked-prefill&lt;/code>, &lt;code>--gpu-memory-utilization=0.92&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Routing between models&lt;/strong>: LiteLLM as the multi-provider abstraction.&lt;/li>
&lt;li>&lt;strong>Release strategy&lt;/strong>: canary (1% → 10% → 100%), blue-green (all or nothing with fast rollback), shadow (eval in parallel without affecting users).&lt;/li>
&lt;li>&lt;strong>Autoscaling with LLM metrics&lt;/strong>: KEDA + Prometheus over &lt;code>vllm:num_requests_waiting&lt;/code> or equivalent.&lt;/li>
&lt;li>&lt;strong>Gateway / Inference Extension&lt;/strong>: Gateway API Inference Extension once it is GA.&lt;/li>
&lt;/ul>
&lt;h3 id="dominant-tools-1">Dominant tools&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Serving engines&lt;/strong>: vLLM, SGLang, TensorRT-LLM, llama.cpp, MLX.&lt;/li>
&lt;li>&lt;strong>Operators&lt;/strong>: OME, vLLM Production Stack, NVIDIA Dynamo, llm-d, KServe.&lt;/li>
&lt;li>&lt;strong>Routing&lt;/strong>: LiteLLM (100+ providers), OpenRouter (managed), LangChain Router.&lt;/li>
&lt;li>&lt;strong>GPU primitives&lt;/strong>: NVIDIA GPU Operator, LeaderWorkerSet (LWS) for multi-pod tensor parallel, KEDA for autoscaling.&lt;/li>
&lt;/ul>
&lt;h3 id="traps-3">Traps&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Naïve rolling update&lt;/strong> that cuts sessions: &lt;code>maxUnavailable: 0, maxSurge: 1&lt;/code> and &lt;code>terminationGracePeriodSeconds: 120+&lt;/code>.&lt;/li>
&lt;li>&lt;strong>A short readiness probe&lt;/strong> that kills pods while they load: &lt;code>startupProbe&lt;/code> with &lt;code>failureThreshold: 60&lt;/code>.&lt;/li>
&lt;li>&lt;strong>HPA on CPU%&lt;/strong> with no LLM metrics: vLLM batches internally, one replica serves dozens. KEDA on queue depth.&lt;/li>
&lt;li>&lt;strong>Unquantised KV cache&lt;/strong>: &lt;code>--kv-cache-dtype=fp8&lt;/code> is almost always worth it.&lt;/li>
&lt;li>&lt;strong>Tensor parallel on GPUs without NVLink&lt;/strong>: all-reduce saturates PCIe and throughput collapses.&lt;/li>
&lt;/ul>
&lt;h2 id="stage-5--observe-seeing-what-happens-in-production">Stage 5 — Observe: seeing what happens in production&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Observe">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#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(#mn5)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mn5)}&lt;/style>
&lt;defs>&lt;marker id="mn5" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: OBSERVE · tracing + metrics + guardrails + drift + eBPF&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box active"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h3 id="operational-sub-tasks-4">Operational sub-tasks&lt;/h3>
&lt;p>This is the stage we have covered most deeply in previous series: the whole eBPF series (4 posts) and the whole post-tracing series (4 posts) deal with sub-tasks of Observe. A structured summary:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tracing&lt;/strong>: OpenLLMetry/Traceloop, Langfuse, Phoenix, LangSmith. Spans with OTel GenAI semantic conventions (&lt;code>gen_ai.*&lt;/code>, &lt;code>mcp.*&lt;/code>).&lt;/li>
&lt;li>&lt;strong>Metrics&lt;/strong>: Prometheus + Grafana. TTFT, TPOT, throughput, queue depth, KV cache usage, cost per tool.&lt;/li>
&lt;li>&lt;strong>Active guardrails&lt;/strong> (not just eval): NeMo Guardrails with 5 types of rails, multimodal Llama Guard 4, Llama Prompt Guard 2 (86M/22M), LLM Guard.&lt;/li>
&lt;li>&lt;strong>eBPF observability&lt;/strong> (zero-instrumentation): Hubble (network), Tetragon (process/syscall), AgentSight (LLM agent with SSL uprobes + stdiocap MCP).&lt;/li>
&lt;li>&lt;strong>eBPF in the local engine&lt;/strong> (inference): ProfInfer-style with uprobes in llama.cpp / vLLM / libcudart.&lt;/li>
&lt;li>&lt;strong>Drift detection&lt;/strong>: Evidently AI, NannyML, WhyLabs. KS, PSI, MMD over embeddings.&lt;/li>
&lt;li>&lt;strong>MCP observability&lt;/strong>: OpenTelemetry GenAI MCP semantic conventions, trace propagation via &lt;code>params._meta&lt;/code>, a centralised MCP Gateway.&lt;/li>
&lt;/ul>
&lt;h3 id="the-four-mandatory-metrics">The four mandatory metrics&lt;/h3>
&lt;p>Out of everything covered, the four any minimal dashboard must have:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>TTFT p50/p95&lt;/strong> (time to first token) — what the user perceives.&lt;/li>
&lt;li>&lt;strong>TPOT p50/p95&lt;/strong> (time per output token) — streaming speed.&lt;/li>
&lt;li>&lt;strong>Throughput&lt;/strong> (aggregate tokens per second) — capacity planning.&lt;/li>
&lt;li>&lt;strong>Queue depth&lt;/strong> (&lt;code>vllm:num_requests_waiting&lt;/code>) — the leading indicator.&lt;/li>
&lt;/ol>
&lt;p>To these you add, per domain:&lt;/p>
&lt;ul>
&lt;li>For RAG: faithfulness rolling mean, retrieval hit rate.&lt;/li>
&lt;li>For agents: tool call accuracy, multi-step task completion.&lt;/li>
&lt;li>For multi-tenant: cost per tenant, p95 latency per tenant.&lt;/li>
&lt;/ul>
&lt;h3 id="traps-4">Traps&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Cardinality in Prometheus&lt;/strong>: metrics carrying every K8s label blow up.&lt;/li>
&lt;li>&lt;strong>Tracing with no sampling&lt;/strong>: storage grows out of control.&lt;/li>
&lt;li>&lt;strong>Guardrails permanently in monitoring mode&lt;/strong>: they never reach enforce.&lt;/li>
&lt;li>&lt;strong>Drift with no alerts&lt;/strong>: you spot drift on the dashboard once a month; meanwhile the problem has been running for weeks.&lt;/li>
&lt;li>&lt;strong>OTel with no propagation&lt;/strong>: MCP, Tetragon and AgentSight spans left disconnected.&lt;/li>
&lt;/ul>
&lt;h2 id="stage-6--retrain-closing-the-loop">Stage 6 — Retrain: closing the loop&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: Retrain">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ffd24a;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(#mn6)}.cyc{stroke:#c66;stroke-width:2;fill:none;stroke-dasharray:4 2;marker-end:url(#mn6)}&lt;/style>
&lt;defs>&lt;marker id="mn6" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#c66"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: RETRAIN · closing the loop back to DATA&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box active"/>&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;h3 id="operational-sub-tasks-5">Operational sub-tasks&lt;/h3>
&lt;p>This is the stage most neglected in GenAI projects. Closing the loop turns LLMOps into a living practice; not closing it leaves it as a project that ages.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Explicit feedback&lt;/strong>: thumbs up/down in the UI, annotations by power users, forms for &amp;ldquo;what went wrong&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Implicit feedback&lt;/strong>: anomalous latency, abandonment rate, user retries, aborted sessions.&lt;/li>
&lt;li>&lt;strong>Incident triaging&lt;/strong>: classify incidents by root cause (model issue, retrieval issue, prompt issue, infra issue).&lt;/li>
&lt;li>&lt;strong>Dataset enrichment&lt;/strong>: add to the golden dataset the cases where the system failed, with the correct answer labelled by a human.&lt;/li>
&lt;li>&lt;strong>Retrain cadence&lt;/strong>: quarterly by default, &lt;strong>incident-driven&lt;/strong> when a problematic pattern goes over a threshold.&lt;/li>
&lt;li>&lt;strong>Promotion&lt;/strong>: the new model/adapter goes through the Tune → Eval → Deploy stages, with eval gates that compare against the model in production.&lt;/li>
&lt;/ul>
&lt;h3 id="the-two-cadences">The two cadences&lt;/h3>
&lt;p>&lt;strong>Scheduled retrain&lt;/strong> (quarterly or half-yearly): an established process. It lets you plan capacity, budget and risk. The default.&lt;/p>
&lt;p>&lt;strong>Incident-driven retrain&lt;/strong>: when a serious incident (detected drift, a failing segment, a prompt injection attack) crosses a threshold, a mini-cycle fires. More expensive but necessary for critical cases.&lt;/p>
&lt;h3 id="dominant-tools-2">Dominant tools&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Annotation and feedback collection&lt;/strong>: Langfuse (built-in UI), Argilla (OSS), Label Studio.&lt;/li>
&lt;li>&lt;strong>Dataset enrichment&lt;/strong>: pipelines in Airflow or Argo Workflows.&lt;/li>
&lt;li>&lt;strong>Triaging&lt;/strong>: Langfuse dashboards with filters on traces with a low eval.&lt;/li>
&lt;li>&lt;strong>Promoting candidates&lt;/strong>: MLflow model registry stages.&lt;/li>
&lt;/ul>
&lt;h3 id="traps-5">Traps&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>An open loop&lt;/strong>: production does not inform the dataset; the model never improves.&lt;/li>
&lt;li>&lt;strong>Human feedback gets lost&lt;/strong>: thumbs down with no structured capture channel.&lt;/li>
&lt;li>&lt;strong>Undefined cadence&lt;/strong>: &amp;ldquo;we retrain when it is needed&amp;rdquo; → nobody ever retrains.&lt;/li>
&lt;li>&lt;strong>No holdout test set&lt;/strong>: the golden dataset is enriched with the same cases used to evaluate; eval measures memorisation.&lt;/li>
&lt;li>&lt;strong>Promotion with no gates&lt;/strong>: the new model enters production without passing the checks the previous models passed.&lt;/li>
&lt;/ul>
&lt;h2 id="the-complete-cycle-how-the-stages-fit-together">The complete cycle: how the stages fit together&lt;/h2>
&lt;p>Now that we have seen each stage separately, the key insight is &lt;strong>how they hook into each other&lt;/strong>. Five emergent properties of the cycle:&lt;/p>
&lt;p>&lt;strong>1. Data is the raw material of every stage&lt;/strong>. Tune reads from the golden dataset. Eval reads from the eval dataset. Deploy reads from the RAG (vector store). Observe produces new data. Retrain creates new datasets. &lt;strong>The Kafka log is the gospel of the whole system&lt;/strong> (post 2 in the series).&lt;/p>
&lt;p>&lt;strong>2. Eval is the bidirectional gatekeeper&lt;/strong>. Before Deploy: it blocks the release if the model regresses. After Observe: it feeds Retrain by identifying the worst-scoring cases. The quality of the eval determines the quality of the whole cycle.&lt;/p>
&lt;p>&lt;strong>3. Observe feeds Retrain and Eval simultaneously&lt;/strong>. Traces produce metrics for Observe; problematic traces are annotated and go to the dataset; the new cases enrich the eval golden set. &lt;strong>Observe is the operational source of truth&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>4. The cross-cutting components (the grey band on the map) are not a stage, they are an infrastructure&lt;/strong>. OpenTelemetry, prompt versioning, MCP gateway, model gateway, schema registry. Badly configured, every stage suffers separately. Well configured, the stages integrate without friction.&lt;/p>
&lt;p>&lt;strong>5. The cycle is not strictly sequential, it is concurrent&lt;/strong>. At any given moment the system has: requests being served (Deploy + Observe), a new version in training (Tune), continuous eval in CI (Eval), data arriving from CDC (Data), incident analysis (Retrain). &lt;strong>Every stage is alive at once&lt;/strong>.&lt;/p>
&lt;h2 id="cross-stage-traps-things-that-break-the-whole-system">Cross-stage traps: things that break the whole system&lt;/h2>
&lt;p>There are errors that do not belong to a stage but to the interfaces between stages. The most common ones:&lt;/p>
&lt;h3 id="trainserve-skew">Train/serve skew&lt;/h3>
&lt;p>The exact format of the prompt in training is different from the one in production. The result: the model trained to answer &lt;code>&amp;lt;|im_start|&amp;gt;user\n...\n&amp;lt;|im_end|&amp;gt;&lt;/code> receives &lt;code>User: ...\nAssistant:&lt;/code> in production and performs worse. &lt;strong>Solution&lt;/strong>: extract the chat template into a shared library used by the Tune pipeline &lt;strong>and&lt;/strong> the Deploy one.&lt;/p>
&lt;h3 id="an-eval-that-does-not-reflect-production">An eval that does not reflect production&lt;/h3>
&lt;p>Your golden dataset is careful questions; production is real questions with typos, mixed languages, and so on. Eval passes at 95%, production performs at 70%. &lt;strong>Solution&lt;/strong>: continuously enrich the golden set with real samples.&lt;/p>
&lt;h3 id="drift-with-no-response-pipeline">Drift with no response pipeline&lt;/h3>
&lt;p>You spot drift on the Observe dashboard; nobody has a defined workflow for what to do. &lt;strong>Solution&lt;/strong>: every drift alert must have a clear runbook: investigate, classify, act (retrain, adjust the prompt, widen the retrieval).&lt;/p>
&lt;h3 id="schema-break-cascade">Schema break cascade&lt;/h3>
&lt;p>You change the schema at the OLTP source; Debezium reflects it; the Flink job breaks; the embedded topic stops updating; the vector store ages; the RAG answers over old data. Three stages affected by one change in Data. &lt;strong>Solution&lt;/strong>: mandatory &lt;strong>backward-compatible&lt;/strong> schema evolution, contracts between producers and consumers.&lt;/p>
&lt;h3 id="no-observability-of-the-pipeline-itself">No observability of the pipeline itself&lt;/h3>
&lt;p>The LLMOps pipeline is a complex system. If it has no observability of its own (how long training takes, how many jobs fail, how many re-embeddings run), debugging failures is an exercise in spelunking. &lt;strong>Solution&lt;/strong>: OTel over the pipeline itself, not just over the LLM calls.&lt;/p>
&lt;h3 id="invisible-vendor-lock-in">Invisible vendor lock-in&lt;/h3>
&lt;p>Pipelines written against LangChain, prompts stuck in LangSmith, embeddings in Pinecone, the model in OpenAI. Migrating is a months-long project. &lt;strong>Solution&lt;/strong>: LiteLLM and OpenLLMetry abstractions, vendor-neutral from the start.&lt;/p>
&lt;h2 id="what-comes-in-the-following-posts">What comes in the following posts&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Post 4 — &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in the ingestion stage&lt;/a>&lt;/strong> — the first post that applies the &amp;ldquo;you are here&amp;rdquo; pattern over the Data stage. Synchronisation patterns (outbox + CDC), microservices architecture, deployment manifests.&lt;/li>
&lt;li>&lt;strong>Upcoming posts&lt;/strong> — still to be decided: the cluster as a multi-tenant platform, Constitutional AI / runtime alignment, continuous fine-tuning in depth, edge LLMs.&lt;/li>
&lt;li>In any later post in this or future series, the &lt;strong>&amp;ldquo;you are here&amp;rdquo; mini-map&lt;/strong> will tell you which stage of the cycle the topic fits into. If you read a post about quantization, you will know you are in Deploy. If you read one about evaluator ensembles, you will know you are in Eval. If you read one about RAG over Iceberg, you will know you are in Data.&lt;/li>
&lt;li>If you want to see &lt;strong>the whole pipeline in action following a single real request&lt;/strong>, the synthesis post &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> does exactly that: it rewinds a request back to the data that trained it 90 days earlier and follows it forward to the feedback that will reappear in the next Retrain cycle, crossing the six stages and the two cross-cutting components in one coherent story.&lt;/li>
&lt;li>If what interests you is &lt;strong>comparing how each stage is built in open source against the hyperscalers&lt;/strong>, the post &lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">The parallel catalogue: the six LLMOps stages in OSS and in AWS / GCP / Azure&lt;/a> makes the vertical cut: for every stage, which tools the blog&amp;rsquo;s reference OSS stack uses and what the cloud equivalents are, with summary tables, gap identification and the multi-tenant chatbot ported to an AWS stack as a concrete example.&lt;/li>
&lt;li>If you want the &lt;strong>OSS toolbox piece by piece&lt;/strong>, the post &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: entry by entry&lt;/a> does the zoom in: ~150 words of description per core tool (what it does, how it differs from its alternatives, licence and governance, typical gotcha), a decision matrix per stage, a diagram of the connected OSS stack and a master table of licences and EE / SaaS offerings.&lt;/li>
&lt;li>If what you are missing is &lt;strong>the common vocabulary that runs through the six stages&lt;/strong>, the post &lt;a href="https://blog.lo0.es/en/posts/ontologies-knowledge-graphs-llmops-linnaean-nomenclature/">Ontologies and knowledge graphs in LLMOps&lt;/a> walks the six stages from the perspective of shared formal nomenclature: how TBox + ABox + SHACL + SKOS change the operation of Data, Train/Adapt, Eval, Deploy, Observe and Govern, the GraphRAG 2026 landscape (Microsoft GraphRAG v2, LightRAG, HippoRAG 2, KAG/OpenSPG), vertical ontologies actually deployed (FIBO, SNOMED CT, schema.org, ENS, EU AI Act) and an open source stack with its licensing caveats (Neo4j Community GPLv3, KuzuDB upstream archived in October 2025).&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Foundations:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/@sanjeebmeister/the-complete-mlops-llmops-roadmap-for-2026-building-production-grade-ai-systems-bdcca5ed2771">The Complete MLOps/LLMOps Roadmap for 2026 (Sanjeeb Panda)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://hyscaler.com/insights/mlops-in-2026-guide/">MLOps in 2026: Architecture, Trends &amp;amp; Strategy (Hyscaler)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Per stage (entries from the blog series):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Data&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka — technical architecture&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">dedicated post on data versioning with DVC and lakeFS&lt;/a> — the four artefacts to version separately (training, RAG corpus, golden eval, enriched retrain), schema contracts, end-to-end lineage from dataset to trace, and why a golden set without a strict holdout measures memorisation.&lt;/li>
&lt;li>&lt;strong>Tune&lt;/strong>: partly covered in &lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">Landscape 2026&lt;/a>; a deeper treatment in post 4 if continuous fine-tuning is chosen.&lt;/li>
&lt;li>&lt;strong>Eval&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Deploy&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM operators on K8s&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Observe&lt;/strong>: the whole eBPF series and the whole post-tracing series.&lt;/li>
&lt;li>&lt;strong>Retrain&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">the dedicated post on how to close the loop&lt;/a> — feedback capture (explicit + implicit), triage by root cause, dataset enrichment with human annotation (Argilla / Label Studio), scheduled vs incident-driven cadences, governed promotion with eval gates.&lt;/li>
&lt;/ul>
&lt;p>Cross-cutting components:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Prompt versioning&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">the dedicated post with Langfuse and MLflow Prompts&lt;/a> — the three-primitive pattern (immutable version, mutable label, cache), eval gates on promotion, and per-request traceability.&lt;/li>
&lt;li>&lt;strong>MCP&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">deep MCP observability&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Drift detection&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift detection&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Local inference&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Frameworks and tools referenced:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mlflow.org/">MLflow&lt;/a>, &lt;a href="https://wandb.ai/">W&amp;amp;B&lt;/a>, &lt;a href="https://www.kubeflow.org/">Kubeflow&lt;/a>, &lt;a href="https://www.zenml.io/">ZenML&lt;/a>, &lt;a href="https://www.bentoml.com/">BentoML&lt;/a>, &lt;a href="https://metaflow.org/">Metaflow&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/huggingface/peft">HuggingFace PEFT&lt;/a>, &lt;a href="https://github.com/huggingface/trl">TRL&lt;/a>, &lt;a href="https://github.com/axolotl-ai-cloud/axolotl">Axolotl&lt;/a>, &lt;a href="https://github.com/unslothai/unsloth">Unsloth&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://dvc.org/">DVC&lt;/a> + &lt;a href="https://lakefs.io/">lakeFS&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/">Langfuse&lt;/a>, &lt;a href="https://www.evidentlyai.com/">Evidently AI&lt;/a>, &lt;a href="https://phoenix.arize.com/">Phoenix&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/vllm-project/production-stack">vLLM Production Stack&lt;/a>, &lt;a href="https://kserve.github.io/website/">KServe&lt;/a>, &lt;a href="https://github.com/ome-projects/ome">OME&lt;/a>, &lt;a href="https://developer.nvidia.com/dynamo">NVIDIA Dynamo&lt;/a>, &lt;a href="https://github.com/llm-d/llm-d">llm-d&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>RAG on Kafka: a technical reference architecture for streaming datalakes, with fresh embeddings and vector stores that are always up to date</title><link>https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/</link><pubDate>Thu, 21 May 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The piece that blocks enterprise GenAI projects most in 2026 is not the model, nor even the guardrails: it is &lt;strong>data ingestion for RAG&lt;/strong>. Companies hold valuable information in OLTP databases, in operational logs, in SaaS systems, and all of it is quietly changing every second. Batch RAG systems that reindex every night arrive late (the model&amp;rsquo;s answer is backed by a snapshot from 18 hours ago) and open the door to operational hallucinations even when the retriever is perfect. The dominant answer in production in 2026 is to build the RAG piece on top of Kafka as the source of truth: an immutable log, massive throughput, managed schema evolution, and a mature stream processing ecosystem (Flink, Kafka Streams, RisingWave) that makes it possible to transform and embed events as they happen, delivering them to vector stores (Milvus, Qdrant, Weaviate, pgvector) in milliseconds. The canonical pattern: source → CDC with Debezium → Kafka topics → Flink SQL with an embedding UDF → sink connector to a vector store → serving with vLLM or equivalent. The 2026 developments that change the game: Confluent Tableflow turns Kafka topics into Iceberg/Delta tables automatically (read from Snowflake/Databricks/Trino with no ETL, 30-50 % lower TCO); native Flink SQL brings &lt;code>openai_embedding()&lt;/code> and vector search integrated with Cosmos DB and Amazon S3 Vectors; Confluent&amp;rsquo;s official MCP server lets AI agents query Kafka/Flink/Tableflow in natural language. This post develops the end-to-end architecture with manifests, Flink SQL code and concrete numbers.&lt;/p>
&lt;blockquote>
&lt;p>This is the second post in the MLOps series specific to LLMs. The first (&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">2026 landscape&lt;/a>) set out the framework. Here we go down to the most operational piece of the stack: how a real enterprise system connects to an LLM agent while keeping RAG fresh without falling into explosive complexity.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-kafka-as-the-single-source-of-truth">The analogy: Kafka as the &amp;ldquo;single source of truth&amp;rdquo;&lt;/h2>
&lt;p>Anyone who has spent time in distributed systems has seen the pattern again and again: an immutable, append-only, replicated, time-ordered log has become the canonical primitive for rebuilding complex systems. DBAs know it as the write-ahead log (PostgreSQL WAL, MySQL binlog). Event system developers know it as event sourcing. Data architects know it as the Kappa architecture. Kafka is the massive, distributed, mature implementation of that primitive: a log that lives on disk, partitioned to scale, replicated for durability, retained by time or size, readable from any point in history.&lt;/p>
&lt;p>When you think about RAG, this is exactly what you need. A well-designed RAG system has two critical questions: how do you keep the index fresh? And how do you rebuild the index when something breaks? Kafka answers both naturally: fresh because every change at the source is published as an event to the log and the pipeline processes it in milliseconds; rebuildable because the whole log is there: you drop the vector store, you have the Kafka topic from offset 0, and you build the index back exactly as it was.&lt;/p>
&lt;p>There is a second layer to the analogy as well. For a modern GenAI architecture, Kafka plays the role of the WAL of the entire system. Just as the Postgres WAL is the gospel of the database&amp;rsquo;s state (if you lose the DB but keep the WAL, you can rebuild it), the Kafka log is the gospel of the state of the business as a whole: orders, users, transactions, documents. Connecting your AI agent to Kafka means connecting it to the real pulse of the system, not to stale snapshots.&lt;/p>
&lt;h2 id="the-static-rag-problem">The static RAG problem&lt;/h2>
&lt;p>Before presenting the architecture, it is worth pinning down what concrete problem we are solving. The antipattern that trips up most GenAI projects:&lt;/p>
&lt;ol>
&lt;li>A team builds RAG on a static dataset: it dumps Confluence documents, product PDFs, database snapshots.&lt;/li>
&lt;li>It embeds the lot with a nightly cron that regenerates the index every 24 hours.&lt;/li>
&lt;li>It ships the product.&lt;/li>
&lt;li>&lt;strong>Day 2&lt;/strong>: a user asks about a change that happened two hours ago. RAG does not have it; the model answers about the old version.&lt;/li>
&lt;li>The team adds fragile logic: &amp;ldquo;if the query mentions a recent date, escalate to a human agent&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Day 30&lt;/strong>: the dataset has moved so much that half the RAG is out of date. The team decides to refactor and migrate to streaming.&lt;/li>
&lt;/ol>
&lt;p>It is the repeated story of so many projects that the ecosystem has learned the lesson: streaming from day 1, even when the volume is low. The operational complexity of a well-designed streaming pipeline is constant; the complexity of migrating from batch to streaming on a live project is enormous.&lt;/p>
&lt;h2 id="from-lambda-to-kappa-to-streaming-rag">From Lambda to Kappa to Streaming RAG&lt;/h2>
&lt;p>Three architectures in historical order:&lt;/p>
&lt;p>&lt;strong>Lambda (the classic big-data design from 2014)&lt;/strong>: two parallel pipelines, one batch for accuracy and one streaming for freshness. The query combines both. It works, but it means maintaining two pipelines.&lt;/p>
&lt;p>&lt;strong>Kappa (Jay Kreps, 2014, mainstream since 2020)&lt;/strong>: a single streaming pipeline. Batch is a special case of streaming (reprocessing from the beginning). It simplifies a great deal.&lt;/p>
&lt;p>&lt;strong>Streaming RAG (emerging in 2025-2026)&lt;/strong>: a specific variant of Kappa where the pipeline&amp;rsquo;s output is embeddings indexed in a vector store that the LLM queries at runtime. The Kafka log is the source of truth, the vector store is a queryable projection.&lt;/p>
&lt;p>The mental conversion: think of the vector store as the materialised view of the Kafka log. If the view is corrupted, you rebuild it from the log. If you want a new view (a different embedding model, a different chunking strategy), you create another consumer of the log and build a second view in parallel.&lt;/p>
&lt;h2 id="the-reference-architecture">The reference architecture&lt;/h2>
&lt;p>On to the diagram. I am going to present the canonical architecture that stabilised in 2026, showing where each component fits:&lt;/p>
&lt;pre tabindex="0">&lt;code>[OLTP DB (Postgres)] [Other sources]
│ │
│ WAL via logical decoding │
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Debezium / Kafka Connect (Sources) │
└──────────────────────────────────────────────────────────┘
│
▼ produces events
┌──────────────────────────────────────────────────────────┐
│ Kafka cluster │
│ ┌───────────────────────────────────────────────────┐ │
│ │ topic: orders.raw (3 partitions, RF=3) │ │
│ │ topic: users.raw (3 partitions, RF=3) │ │
│ │ topic: documents.raw (6 partitions, RF=3) │ │
│ └───────────────────────────────────────────────────┘ │
│ + Schema Registry (Avro/Protobuf) │
└──────────────────────────────────────────────────────────┘
│
▼ consumes and transforms
┌──────────────────────────────────────────────────────────┐
│ Flink SQL streaming jobs │
│ - chunking text │
│ - calls to the embedding model (UDF) │
│ - enrichment with metadata │
│ - sink to a curated topic: documents.embedded │
└──────────────────────────────────────────────────────────┘
│
┌───────────┼────────────────────┐
▼ ▼ ▼
[Vector store] [Tableflow] [Iceberg/Delta]
Milvus/Qdrant auto-convert for analytics
/pgvector/ topics →
Weaviate tables
│
▼ queried at runtime
┌──────────────────────────────────────────────────────────┐
│ LLM serving (vLLM / SGLang) + Retriever │
│ - receives the agent&amp;#39;s query │
│ - searches top-K in the vector store │
│ - builds prompt + context │
│ - generates an answer with citations │
└──────────────────────────────────────────────────────────┘
&lt;/code>&lt;/pre>&lt;p>The five layers you can see (source, ingestion (CDC), transport (Kafka), processing (Flink), storage (vectors + tables)) are the ones that structure any serious RAG on a datalake in 2026. Let us go through each one.&lt;/p>
&lt;h2 id="layer-1--sources-your-oltp-as-the-starting-point">Layer 1 — Sources: your OLTP as the starting point&lt;/h2>
&lt;p>The typical source is an OLTP database (Postgres, MySQL, SQL Server). It is where the live state of the business lives. The technique for extracting changes in real time is Change Data Capture (CDC): reading the database&amp;rsquo;s transaction log (PostgreSQL WAL, MySQL binlog) and turning each commit into a Kafka event.&lt;/p>
&lt;p>The OSS standard is &lt;a href="https://debezium.io/">Debezium&lt;/a>. It supports Postgres, MySQL, SQL Server, MongoDB, Oracle, Cassandra and others. The typical deployment is a Kafka Connect cluster with Debezium connectors.&lt;/p>
&lt;p>An example Debezium configuration for PostgreSQL:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgres-orders-connector&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;config&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;connector.class&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.debezium.connector.postgresql.PostgresConnector&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;tasks.max&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.hostname&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgres.prod.internal&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.port&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;5432&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.user&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;debezium&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.password&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;${secret:postgres-creds}&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.dbname&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ecommerce&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.server.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ecommerce-prod&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;table.include.list&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;public.orders,public.users,public.products&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;publication.autocreate.mode&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;filtered&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;slot.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;debezium_slot&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;plugin.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;pgoutput&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;topic.prefix&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ecommerce&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;key.converter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.confluent.connect.avro.AvroConverter&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;value.converter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.confluent.connect.avro.AvroConverter&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;key.converter.schema.registry.url&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;http://schema-registry:8081&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;value.converter.schema.registry.url&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;http://schema-registry:8081&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For each commit in the database, this produces an Avro event on the corresponding topic (&lt;code>ecommerce.public.orders&lt;/code>, &lt;code>ecommerce.public.users&lt;/code>, and so on) carrying the change: type (INSERT/UPDATE/DELETE), values before and after, commit timestamp, position in the WAL.&lt;/p>
&lt;p>A simpler alternative for 2026: &lt;a href="https://risingwave.com/">RisingWave&lt;/a> can read the Postgres WAL directly, with no Debezium or Kafka Connect in between. When the case is CDC only, with no other sources, it is operationally simpler. For architectures with multiple sources (CDC + APIs + scrapers + logs), Debezium remains the standard piece.&lt;/p>
&lt;h2 id="layer-2--kafka-as-transport-and-persistence">Layer 2 — Kafka as transport and persistence&lt;/h2>
&lt;p>The Kafka cluster is where all events land. Key operational decisions:&lt;/p>
&lt;h3 id="topics-raw-vs-curated">Topics: raw vs curated&lt;/h3>
&lt;p>The convention that settled in 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>*.raw&lt;/code>&lt;/strong>: the raw event exactly as it arrived. Untransformed CDC, unparsed application logs.&lt;/li>
&lt;li>&lt;strong>&lt;code>*.cleaned&lt;/code>&lt;/strong>: after dedup, schema validation, type normalisation.&lt;/li>
&lt;li>&lt;strong>&lt;code>*.enriched&lt;/code>&lt;/strong>: after adding metadata (geolocation, cross identifiers, and so on).&lt;/li>
&lt;li>&lt;strong>&lt;code>*.embedded&lt;/code>&lt;/strong>: the event with its vector embedding already computed.&lt;/li>
&lt;/ul>
&lt;p>Multi-stage topics allow debugging per layer and partial reprocessing: if you change the embedding model, discarding &lt;code>*.embedded&lt;/code> and rebuilding from &lt;code>*.enriched&lt;/code> costs hours; rebuilding from &lt;code>*.raw&lt;/code> costs days.&lt;/p>
&lt;h3 id="schema-registry">Schema Registry&lt;/h3>
&lt;p>Without a schema registry, topics break silently when someone changes the schema at the source. &lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html">Confluent Schema Registry&lt;/a> or the OSS &lt;a href="https://www.apicur.io/registry/">Apicurio&lt;/a> are the dominant options.&lt;/p>
&lt;p>Common formats:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Avro&lt;/strong>: versioned schema, strict evolution rules. The historical default.&lt;/li>
&lt;li>&lt;strong>Protobuf&lt;/strong>: compatible with gRPC stacks, good performance.&lt;/li>
&lt;li>&lt;strong>JSON Schema&lt;/strong>: textual, debuggable by eye, less efficient.&lt;/li>
&lt;/ul>
&lt;p>For RAG on Kafka we recommend Avro by default. Schema evolution matters because source tables change over time, and an unversioned schema breaks downstream consumers.&lt;/p>
&lt;h3 id="partitions-replication-and-retention">Partitions, replication and retention&lt;/h3>
&lt;p>Operational decisions for RAG topics:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Partitions&lt;/strong>: typically 3-12. More partitions means more parallelism in the Flink consumer, but more overhead. The rule of thumb: partitions = expected peak events/s ÷ 1000.&lt;/li>
&lt;li>&lt;strong>Replication factor&lt;/strong>: 3 as a minimum in production. Replication protects against broker failure; with RAG, the cost of losing a topic can be weeks of re-embedding.&lt;/li>
&lt;li>&lt;strong>Retention&lt;/strong>: for topics that feed RAG, use long retention or compaction by key. If document &lt;code>doc-42&lt;/code> changes 100 times, compaction keeps only the last state per key, leaving a smaller, rebuildable log. For data that is never updated (historical logs), retention by time (90 days, 1 year).&lt;/li>
&lt;/ul>
&lt;h3 id="cross-cluster-replication">Cross-cluster replication&lt;/h3>
&lt;p>For multi-region or multi-cloud deployments, MirrorMaker 2 or &lt;a href="https://docs.confluent.io/platform/current/multi-dc-deployments/cluster-linking/index.html">Cluster Linking&lt;/a> (Confluent) replicate topics between Kafka clusters. RAG can then query the local cluster without crossing regions.&lt;/p>
&lt;h2 id="layer-3--flink-as-the-streaming-processor">Layer 3 — Flink as the streaming processor&lt;/h2>
&lt;p>&lt;a href="https://flink.apache.org/">Apache Flink&lt;/a> is the dominant stream processing piece in 2026. Apache 2.0, a mature distribution, a broad ecosystem. The main alternative is Kafka Streams (simpler, Java-only); RisingWave is the emerging option for pure SQL cases.&lt;/p>
&lt;p>What Flink adds to Kafka:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Stateful streaming&lt;/strong>: time-based aggregations, joins between streams, sessions.&lt;/li>
&lt;li>&lt;strong>Exactly-once semantics&lt;/strong>: with checkpoint coordination.&lt;/li>
&lt;li>&lt;strong>Watermarks&lt;/strong>: correct handling of out-of-order events.&lt;/li>
&lt;li>&lt;strong>UDFs in Python/Java&lt;/strong>: including calls to LLM models.&lt;/li>
&lt;/ul>
&lt;h3 id="flink-sql-the-most-operational-piece">Flink SQL: the most operational piece&lt;/h3>
&lt;p>Flink SQL is the most usable part of Flink for data engineers who are not streaming experts. Here is a realistic RAG pipeline example:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- 1. Define the source: Kafka topic with CDC events from documents
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_raw&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">body&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">category&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMP_LTZ&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ENFORCED&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;connector&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;upsert-kafka&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;topic&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;ecommerce.public.documents&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;properties.bootstrap.servers&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;kafka:9092&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;key.format&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;avro-confluent&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;value.format&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;avro-confluent&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;value.fields-include&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;EXCEPT_KEY&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 2. Define the sink: vector store via an intermediate Kafka topic
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_embedded&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">category&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">ARRAY&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="nb">FLOAT&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">embedded_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMP_LTZ&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ENFORCED&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;connector&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;upsert-kafka&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;topic&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;rag.documents.embedded&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;properties.bootstrap.servers&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;kafka:9092&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;key.format&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;json&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;value.format&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;json&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 3. UDF for chunking (defined in Python or Java)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- CREATE TEMPORARY FUNCTION chunk_text AS &amp;#39;com.example.ChunkingUDF&amp;#39;;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 4. Pipeline: chunk, embed, write to the sink
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_embedded&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_idx&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">chunk&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_text&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">category&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">OPENAI_EMBEDDING&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunk&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;text-embedding-3-small&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">CURRENT_TIMESTAMP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedded_at&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_raw&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CROSS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">UNNEST&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunk_text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">body&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">512&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">64&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDINALITY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunk&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_idx&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What happens here, line by line:&lt;/p>
&lt;ul>
&lt;li>The &lt;code>documents_raw&lt;/code> table reads the CDC topic in upsert-kafka mode (each new event on the same key replaces the previous one). This correctly reflects the semantics of &amp;ldquo;this is the latest version of doc 42&amp;rdquo;.&lt;/li>
&lt;li>The &lt;code>documents_embedded&lt;/code> table will be the intermediate topic where Flink writes the embedded chunks.&lt;/li>
&lt;li>The &lt;code>chunk_text&lt;/code> UDF (defined in Python or Java) splits each doc into 512-token chunks with an overlap of 64.&lt;/li>
&lt;li>The &lt;code>INSERT INTO&lt;/code> query runs continuously: every new event in &lt;code>documents_raw&lt;/code> is chunked, each chunk is embedded with &lt;code>OPENAI_EMBEDDING&lt;/code> (a built-in Flink SQL function in Confluent Cloud 2026), and written to the embedded topic.&lt;/li>
&lt;/ul>
&lt;p>&lt;code>OPENAI_EMBEDDING&lt;/code> can be swapped for a custom function that calls a self-hosted model (vLLM with an encoder), SentenceTransformers, or a managed service. The syntax is the same; you change the provider.&lt;/p>
&lt;h3 id="watermarks-and-late-events">Watermarks and late events&lt;/h3>
&lt;p>For cases where an event can arrive late (for example, the Postgres WAL falls behind because of a network blip), Flink lets you define watermarks:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_raw&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">body&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMP_LTZ&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">WATERMARK&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;5&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MINUTE&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(...)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This tells Flink &amp;ldquo;assume no event arrives more than 5 minutes late relative to the event timestamp&amp;rdquo;. For temporal joins and aggregations, Flink uses the watermark to decide when to &amp;ldquo;close&amp;rdquo; a window.&lt;/p>
&lt;h2 id="layer-4--sinks-to-vector-stores">Layer 4 — Sinks to vector stores&lt;/h2>
&lt;p>The last step is indexing the embeddings in a vector store. Three patterns in 2026:&lt;/p>
&lt;h3 id="pattern-a--direct-kafka-connect-sink">Pattern A — direct Kafka Connect sink&lt;/h3>
&lt;p>Each vector store has its official connector:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://github.com/zilliztech/kafka-connect-milvus">Milvus&lt;/a>&lt;/strong>: official Zilliz sink connector. Supports named/unnamed dense/sparse vectors.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://github.com/qdrant/qdrant-kafka">Qdrant&lt;/a>&lt;/strong>: official sink connector. Supports dense, sparse, multi-vector.&lt;/li>
&lt;li>&lt;strong>pgvector&lt;/strong>: has no dedicated connector, but the &lt;a href="https://www.confluent.io/hub/confluentinc/kafka-connect-jdbc">JDBC Sink Connector&lt;/a> is used with custom SQL.&lt;/li>
&lt;li>&lt;strong>Weaviate&lt;/strong>: community connector.&lt;/li>
&lt;li>&lt;strong>LanceDB&lt;/strong>: community connector.&lt;/li>
&lt;/ul>
&lt;p>An example Milvus sink configuration:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;milvus-rag-embeddings-sink&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;config&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;connector.class&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;com.milvus.io.kafka.MilvusSinkConnector&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;tasks.max&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;topics&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rag.documents.embedded&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;milvus.host&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;milvus.prod.internal&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;milvus.port&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;19530&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;milvus.collection.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;documents&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;milvus.collection.dim&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;1536&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;milvus.collection.partition&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;key.converter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;org.apache.kafka.connect.storage.StringConverter&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;value.converter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;org.apache.kafka.connect.json.JsonConverter&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;value.converter.schemas.enable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">false&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Three parallel tasks (&lt;code>tasks.max: 3&lt;/code>) consume the embedded topic and write to the Milvus collection. Latency from &amp;ldquo;event in Kafka&amp;rdquo; to &amp;ldquo;indexable vector in Milvus&amp;rdquo; is typically &amp;lt;5 seconds.&lt;/p>
&lt;h3 id="pattern-b--pgvector-with-a-direct-cdc-pipe">Pattern B — pgvector with a direct CDC pipe&lt;/h3>
&lt;p>For teams that already live in PostgreSQL, pgvector is the lowest-friction option. The pattern: the same source Postgres cluster holds a second DB for embeddings with the pgvector extension enabled; the Flink pipeline writes to it directly over JDBC.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- On the Postgres cluster with pgvector enabled
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">EXTENSION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector&lt;/span>&lt;span class="p">;&lt;/span>&lt;span 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="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">document_embeddings&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">category&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1536&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">embedded_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TIMESTAMP&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">document_embeddings&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">hnsw&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector_cosine_ops&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ef_construction&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">64&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Advantages: the same DBA operates everything, transactionality across tables, trivial joins with relational metadata. Limitation: beyond 10M vectors, pgvector performance starts to give way compared with dedicated systems.&lt;/p>
&lt;h3 id="pattern-c--confluent-tableflow--iceberg--flink-sql-vector-search">Pattern C — Confluent Tableflow → Iceberg + Flink SQL vector search&lt;/h3>
&lt;p>This is the 2026 development that changes the mechanics. &lt;a href="https://www.confluent.io/product/tableflow/">Confluent Tableflow&lt;/a> materialises Kafka topics automatically as Apache Iceberg or Delta Lake tables. Features:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>No ETL pipeline&lt;/strong>: you do not write Flink/Spark jobs to move Kafka into a table. Tableflow does it.&lt;/li>
&lt;li>&lt;strong>Automatic schema evolution&lt;/strong>: changes to the topic schema are reflected in the table.&lt;/li>
&lt;li>&lt;strong>Unified catalogue&lt;/strong>: the table appears in Glue, Unity Catalog, Snowflake, Databricks. Any analytical engine queries it without copying data.&lt;/li>
&lt;li>&lt;strong>Native CDC&lt;/strong>: handles inserts, updates and deletes correctly.&lt;/li>
&lt;li>&lt;strong>30-50 % lower TCO&lt;/strong> according to the figures Confluent publishes against traditional pipelines.&lt;/li>
&lt;/ul>
&lt;p>And since 2026, Tableflow + Flink SQL offer native vector search integrated with Cosmos DB and Amazon S3 Vectors. The RAG query can be made directly in Flink SQL:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_text&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">category&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_embedded&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">VECTOR_SEARCH&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">OPENAI_EMBEDDING&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;user query&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;text-embedding-3-small&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">top_k&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">7&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">VECTOR_SEARCH_SCORE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This unifies layers that used to be separate (vector store + analytics). For many cases, it removes the need to maintain a dedicated vector store.&lt;/p>
&lt;h2 id="confluents-official-mcp-server">Confluent&amp;rsquo;s official MCP server&lt;/h2>
&lt;p>One piece added in 2026 deserves a mention: Confluent has published an official MCP server that exposes Kafka, Flink and Tableflow as tools accessible to AI agents over MCP. Any MCP client (Claude Desktop, Cursor, your own agents) can:&lt;/p>
&lt;ul>
&lt;li>List topics, read recent messages, publish to topics.&lt;/li>
&lt;li>Run Flink SQL queries in natural language (&amp;ldquo;give me the orders from the last 24 hours worth more than 1000 €&amp;rdquo;).&lt;/li>
&lt;li>Query Tableflow Iceberg tables.&lt;/li>
&lt;li>Manage Kafka Connect connectors.&lt;/li>
&lt;/ul>
&lt;p>This closes the circle: as well as reading data from the datalake through RAG (with vector search), your AI agent can write data to the log (through MCP) and trigger transformations (through Flink SQL in natural language). It is the deepest point of fusion between LLM ops and data ops this year.&lt;/p>
&lt;p>A connection with the earlier series: this MCP server emits traces with the OpenTelemetry GenAI MCP semantic conventions we covered in the &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability post&lt;/a>. The spans show up in Langfuse, Phoenix or your OTel backend with the right cardinality. Zero instrumentation code.&lt;/p>
&lt;h2 id="vector-stores-a-2026-comparison">Vector stores: a 2026 comparison&lt;/h2>
&lt;p>The five dominant options:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Vector store&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Operation&lt;/th>
&lt;th>Where it fits&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>pgvector&lt;/strong>&lt;/td>
&lt;td>Postgres ext, OSS&lt;/td>
&lt;td>Your DBA&lt;/td>
&lt;td>&amp;lt;10M vectors, Postgres-heavy team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Qdrant&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Self-host or managed&lt;/td>
&lt;td>Mid-scale, performance focus&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Milvus&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Self-host or Zilliz Cloud&lt;/td>
&lt;td>Large-scale, scalability focus&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Weaviate&lt;/strong>&lt;/td>
&lt;td>BSD-3&lt;/td>
&lt;td>Self-host or managed&lt;/td>
&lt;td>Native hybrid search, semantically rich&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LanceDB&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Embedded or serverless&lt;/td>
&lt;td>Small-medium, simplicity&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The choice depends on:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Scale&lt;/strong>: pgvector falls short beyond 10M vectors. Milvus and Qdrant scale to billions.&lt;/li>
&lt;li>&lt;strong>Hybrid search&lt;/strong>: Weaviate brings lexical + vector natively. Others support it but less integrated.&lt;/li>
&lt;li>&lt;strong>Operation&lt;/strong>: pgvector if you already run Postgres. Qdrant if you want simplicity. Milvus if you need maximum scale.&lt;/li>
&lt;li>&lt;strong>Cloud managed&lt;/strong>: Zilliz Cloud for Milvus, Qdrant Cloud for Qdrant, Pinecone if you want pure SaaS (with no OSS behind it).&lt;/li>
&lt;/ul>
&lt;h2 id="freshness-vs-accuracy-the-operational-trade-off">Freshness vs accuracy: the operational trade-off&lt;/h2>
&lt;p>A critical decision any RAG system on Kafka must answer: when is a new document considered &amp;ldquo;live&amp;rdquo; in the index?&lt;/p>
&lt;p>Three options:&lt;/p>
&lt;p>&lt;strong>Synchronous streaming&lt;/strong>: the event reaches Kafka, Flink embeds it, the sink writes it to the vector store, and only then is it considered live. Typical latency: 1-5 seconds. The best freshness. But if the embedding model fails or the vector store is slow, events pile up in the topic.&lt;/p>
&lt;p>&lt;strong>Asynchronous streaming with a baseline&lt;/strong>: the event is considered live immediately; a background process embeds it when it can. In the meantime, queries asking for that document do not find it. Typical latency: 5-60 seconds. Acceptable for most applications.&lt;/p>
&lt;p>&lt;strong>Micro-batch&lt;/strong>: processing happens in mini-batches every 1-5 minutes. Less efficient than continuous streaming but more stable under variable load. Latency: 1-5 minutes.&lt;/p>
&lt;p>The decision depends on the product&amp;rsquo;s SLA. For customer support chatbots, 5-60 seconds is acceptable. For systems that react to critical events (financial prices, alarms), synchronous streaming is necessary.&lt;/p>
&lt;h2 id="schema-evolution-and-re-embedding">Schema evolution and re-embedding&lt;/h2>
&lt;p>When the embedding model changes (you move from &lt;code>text-embedding-3-small&lt;/code> to &lt;code>text-embedding-3-large&lt;/code>, or from OpenAI to Cohere), the vectors already in the index are incompatible: different dimensions, different semantic spaces. The distance between an old vector and a new one means nothing.&lt;/p>
&lt;p>The standard pattern for handling this: &lt;strong>dual-index&lt;/strong> during the migration.&lt;/p>
&lt;ol>
&lt;li>&lt;strong>T0&lt;/strong>: the active index is V1 (embedding model A).&lt;/li>
&lt;li>&lt;strong>T1&lt;/strong>: a parallel pipeline starts writing to a V2 index (embedding model B), consuming the topic from offset 0 (reprocessing the whole log).&lt;/li>
&lt;li>&lt;strong>T2&lt;/strong>: V2 has caught up with the present.&lt;/li>
&lt;li>&lt;strong>T3&lt;/strong>: you switch the retriever over to V2.&lt;/li>
&lt;li>&lt;strong>T4&lt;/strong>: a week later, you discard V1.&lt;/li>
&lt;/ol>
&lt;p>The Kafka log makes this pattern feasible because it is &lt;strong>immutable and reproducible&lt;/strong>. Without the log, this pattern becomes a weeks-long data migration project.&lt;/p>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;h3 id="topics-without-adequate-retention">Topics without adequate retention&lt;/h3>
&lt;p>Configuring topics with 7-day retention on the grounds that &amp;ldquo;I already have the vector store&amp;rdquo; means losing the ability to rebuild if the vector store fails. &lt;strong>Long retention (90+ days) or compaction by key&lt;/strong> for topics that feed RAG.&lt;/p>
&lt;h3 id="heavy-cdc-during-peak-load">Heavy CDC during peak load&lt;/h3>
&lt;p>Debezium reading the WAL at peak hours can hit the source database&amp;rsquo;s performance. &lt;strong>A dedicated read replica&lt;/strong> for Debezium, not the production primary. Or use logical replication restricted to just the tables you need.&lt;/p>
&lt;h3 id="embedding-cost-run-away">Embedding cost run-away&lt;/h3>
&lt;p>&lt;code>OPENAI_EMBEDDING&lt;/code> on every event of a topic with millions of messages a day comes to thousands of USD per month. Strategies: filter before embedding (only embed what adds value); deduplicate by content hash; use self-hosted open-source embedding models (BGE, E5, GTE) when the cloud cost is prohibitive.&lt;/p>
&lt;h3 id="slow-re-embedding-due-to-limited-throughput">Slow re-embedding due to limited throughput&lt;/h3>
&lt;p>Recomputing 10M embeddings with the OpenAI API at 3000 req/min takes 55 hours. If you wait for an incident to re-embed, that is two days without service. &lt;strong>Embedding throughput is an explicit capacity planning item&lt;/strong>; reserve capacity or keep an offline job ready to start.&lt;/p>
&lt;h3 id="downstream-schema-breaks">Downstream schema breaks&lt;/h3>
&lt;p>A change to the raw topic&amp;rsquo;s schema breaks Flink jobs downstream. &lt;strong>Schema Registry with BACKWARD compatibility made mandatory&lt;/strong>; never ALLOW_ALL. And test schema evolution in CI.&lt;/p>
&lt;h3 id="a-vector-store-with-no-backup">A vector store with no backup&lt;/h3>
&lt;p>Your vector store holds 50M vectors. It is the only copy (the topics expired). A failure wipes it. &lt;strong>Vector stores must be backed up&lt;/strong> like any primary persistence layer. For Milvus/Qdrant: periodic snapshots. For pgvector: plain pg_dump.&lt;/p>
&lt;h3 id="multi-region-without-cross-cluster-replication">Multi-region without cross-cluster replication&lt;/h3>
&lt;p>Your RAG serves users in the US and the EU. The vector store is in US-east. Latency from the EU = 100 ms+ per query. &lt;strong>MirrorMaker or Cluster Linking&lt;/strong> to replicate topics and vector stores in both regions.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Hybrid search in production&lt;/strong>: combining BM25/lexical + vector + reranker. A topic for its own post.&lt;/li>
&lt;li>&lt;strong>Multimodal RAG&lt;/strong>: indexing images, audio and video alongside text. Multimodal embeddings (CLIP, Imagebind), a specific architecture.&lt;/li>
&lt;li>&lt;strong>GraphRAG&lt;/strong>: using structured knowledge (knowledge graphs) as well as vector retrieval. Microsoft GraphRAG, LlamaIndex KnowledgeGraphQueryEngine.&lt;/li>
&lt;li>&lt;strong>RAG with multi-tenant ACLs&lt;/strong>: filtering by permissions at runtime. The pattern uses metadata in the vector store + server-side filters.&lt;/li>
&lt;li>&lt;strong>Query rewriting with an LLM&lt;/strong>: using a first LLM to expand the query before retrieval (HyDE, multi-query, step-back prompting).&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Kafka and stream processing:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://kafka.apache.org/">Apache Kafka&lt;/a> y &lt;a href="https://debezium.io/">Debezium&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html">Confluent Schema Registry&lt;/a> y &lt;a href="https://www.apicur.io/registry/">Apicurio Registry&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://flink.apache.org/">Apache Flink&lt;/a> y &lt;a href="https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/table/sql/overview/">Flink SQL docs&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://risingwave.com/">RisingWave&lt;/a> — alternativa SQL streaming con embedding built-in.&lt;/li>
&lt;/ul>
&lt;p>Vector store connectors:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/zilliztech/kafka-connect-milvus">Milvus Sink Connector (Zilliz, GitHub)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://milvus.io/docs/kafka-connect-milvus.md">Connect Apache Kafka with Milvus (docs)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/qdrant/qdrant-kafka">Qdrant Kafka Sink (GitHub)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://callsphere.ai/blog/vector-database-benchmarks-2026-pgvector-qdrant-weaviate-milvus-lancedb">Vector Database Benchmarks 2026&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://streamkap.com/resources-and-guides/streaming-vector-databases-platform-comparison">Streaming to Vector Databases (Streamkap)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Tableflow and 2026 architecture:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.confluent.io/product/tableflow/">Tableflow — Confluent&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.confluent.io/blog/tableflow-ga-kafka-snowflake-iceberg/">Tableflow GA: Real-Time Kafka to Iceberg (Confluent Blog)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.confluent.io/blog/tableflow-delta-lake-databricks-unity-catalog-ga/">Tableflow + Databricks Unity Catalog (Confluent Blog)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.confluent.io/blog/data-lake-governance-tableflow/">Better-Governed Data Lake Architectures with Tableflow (Confluent Blog)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.kai-waehner.de/blog/2025/12/10/top-trends-for-data-streaming-with-apache-kafka-and-flink-in-2026/">Top Trends for Data Streaming with Kafka and Flink in 2026 (Kai Waehner)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Streaming RAG:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://risingwave.com/blog/rag-architecture-2026/">RAG Architecture in 2026: How to Keep Retrieval Actually Fresh (RisingWave)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://streamkap.com/resources-and-guides/streaming-to-vector-databases">Streaming CDC Events to Vector Databases (Streamkap)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.kai-waehner.de/blog/2023/11/08/apache-kafka-flink-vector-database-llm-real-time-genai/">Apache Kafka + Vector Database + LLM = Real-Time GenAI (Kai Waehner)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/pdf/2508.05662">From Static to Dynamic: A Streaming RAG Approach (arxiv 2508.05662)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://developer.confluent.io/confluent-tutorials/gen-ai-vector-embedding/flinksql/">How to generate vector embeddings for RAG with Flink SQL (Confluent Developer)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://dasroot.net/posts/2026/03/event-driven-architectures-ai-pipelines-kafka-flink/">Event-Driven Architectures for AI Pipelines (dasroot)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Previous post: &lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps specific to LLMs in 2026: the landscape&lt;/a>.&lt;/li>
&lt;li>Data series: &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant at the ingestion stage&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation: the active librarian&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranker and hybrid retrieval&lt;/a> — the four pieces that close the Data block: streaming, ingest, curation and retrieval.&lt;/li>
&lt;li>Post-tracing series: &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/llm-guardrails-safety-four-lines-of-defence/">Guardrails&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;li>eBPF series: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>MLOps specific to LLMs in 2026: the landscape of three modalities, six stages and ten tools that make them work</title><link>https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/</link><pubDate>Thu, 21 May 2026 05:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>This is the blog&amp;rsquo;s fourth series and it is called &lt;strong>MLOps specific to LLMs&lt;/strong>. It takes the operational craft of traditional MLOps (reproducible pipelines, model registries, dataset versioning, eval gates, controlled deployments) and redraws it for a world where the model is probabilistic, the outputs are subjective, the dependencies include external vendors that update weights without warning, and the &amp;ldquo;application&amp;rdquo; is not a model but an orchestration of models, embeddings, retrievers, guardrails and routers. Gartner predicts that more than 50% of enterprise GenAI deployments will fail before the end of 2026, and the main cause is not the model: it is that deterministic software assumptions were applied to probabilistic systems. This post opens the series with the frame: the seven structural differences between LLMOps and classic MLOps; the six-stage pipeline (data → tune → eval → deploy → observe → retrain); the three modalities of preparing a model (continuous fine-tuning, RAG over datalakes, agent training) with their decision matrix, where 60% of 2025-2026 deployments use hybrid because each modality solves a different problem: &amp;ldquo;fine-tune for behaviour, RAG for volatile knowledge&amp;rdquo;; and the 2026 tool landscape, which already forms reasonably stable layers: MLflow 3.10 (March 2026) as a GenAI-aware registry, W&amp;amp;B Weave and ZenML for tracing and pipelines, Kubeflow + KServe vLLM 0.8.1+ for serving, BentoML for flexibility, DVC + lakeFS (joined since November 2025) for data, Langfuse for prompts and observability. The three following posts will go down into the detail of the most critical pieces.&lt;/p>
&lt;blockquote>
&lt;p>This is the opening of series 4: MLOps for LLMs. It continues the tradition of the previous series: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">LLM inference&lt;/a> (the first), &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF&lt;/a> (the second) and &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">post-tracing&lt;/a> (the third). Here we enter the discipline that ties all the pieces together: how an LLM system is operated in production over months, not just deployed once.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-the-sres-craft-redrawn">The analogy: the SRE&amp;rsquo;s craft redrawn&lt;/h2>
&lt;p>Anyone who has worked for years as an SRE or a platform engineer recognises the classic pillars: reproducibility (same code + same data + same config = same result), observability (what happens can be measured), safe rollback (if something goes wrong, I go back within minutes), gradual rollout (the new thing goes to 1% before it goes to 100%). These pillars are not negotiable. The question is whether they hold up when the central component is an LLM.&lt;/p>
&lt;p>The answer is: &lt;strong>same pillars, radically different mechanics&lt;/strong>. Reproducibility: versioning code and data is no longer enough; you have to version prompts, retrieval configurations, snapshots of the vendor&amp;rsquo;s model (which change without warning). Observability: error and latency metrics are no longer enough; you have to measure subjective quality via LLM-as-judge and embedding drift. Rollback: dropping the binary&amp;rsquo;s version is no longer enough; you have to keep the old model cached because loading a new one takes minutes. Gradual rollout: a % of traffic is no longer enough; you have to decide what % of what kind of queries by segment.&lt;/p>
&lt;p>It is the same craft, practised with partly new tools and reflexes. MLOps specific to LLMs, or &amp;ldquo;LLMOps&amp;rdquo;, as the field has christened itself, is the discipline that codifies those reflexes.&lt;/p>
&lt;h2 id="the-seven-structural-differences-between-llmops-and-traditional-mlops">The seven structural differences between LLMOps and traditional MLOps&lt;/h2>
&lt;p>Before going down to the pipeline, let us fix the differences that make this territory new rather than a mere continuation. Each one has concrete practical consequences.&lt;/p>
&lt;h3 id="1-non-deterministic-outputs">1. Non-deterministic outputs&lt;/h3>
&lt;p>Traditional MLOps: the model receives structured input and returns a bounded, reproducible prediction. Same input → same output. Unit tests work.&lt;/p>
&lt;p>LLMOps: same input → different output every time (through sampling, through temperature, through the order of tools invoked, through the retrieval context that changed). The idea of a &amp;ldquo;unit test&amp;rdquo; breaks.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: tests over properties (was the tone maintained?, does it mention the source?, does it respect the JSON schema?), not over equality. Statistical evals over a distribution, not over samples.&lt;/p>
&lt;h3 id="2-behaviour-metrics-not-statistical-accuracy">2. Behaviour metrics, not statistical accuracy&lt;/h3>
&lt;p>Traditional MLOps: F1, accuracy, AUC, RMSE. Metrics with a clear number.&lt;/p>
&lt;p>LLMOps: subjective rubric scores (G-Eval, faithfulness, helpfulness, toxicity), judge LLMs, human feedback. The &amp;ldquo;number&amp;rdquo; depends on who judges.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: platforms have to treat evals as versioned artifacts, not just &amp;ldquo;model v3 scored 0.87&amp;rdquo;, but &amp;ldquo;model v3 evaluated with the judge claude-3-5-sonnet-20251022 over the dataset gold-rag-v7 with the prompt judge-v2 scored 0.87&amp;rdquo;. Versioning the judge is as important as versioning the model being evaluated.&lt;/p>
&lt;h3 id="3-the-model-is-an-external-dependency-not-an-internal-asset">3. The model is an external dependency, not an internal asset&lt;/h3>
&lt;p>Traditional MLOps: you train the model, it lives in your registry, it does not change until you change it.&lt;/p>
&lt;p>LLMOps: the base model belongs to Anthropic, OpenAI, Google, Meta. They change it on you without warning. The &lt;code>claude-3-5-sonnet&lt;/code> version that answered well yesterday answers somewhat differently today.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: drift detection becomes far more critical (&lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">previous post&lt;/a>). Pinning to specific snapshots (&lt;code>claude-3-5-sonnet-20251022&lt;/code>) when the vendor allows it. For high-commitment apps, self-hosting the base model to guarantee reproducibility.&lt;/p>
&lt;h3 id="4-the-application-is-an-orchestration-not-a-model">4. The application is an orchestration, not a model&lt;/h3>
&lt;p>Traditional MLOps: an app calls a model and consumes its output.&lt;/p>
&lt;p>LLMOps 2026: an app connects foundation model + LoRA adapters + retrievers + vector stores + guardrails + routers + tool servers (MCP) + evaluators, all composing the final behaviour. Any component can degrade the result.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: cross-component debugging requires distributed tracing with OTel (covered in previous posts). The registry does not only store &amp;ldquo;the model&amp;rdquo; but the composition: which prompt version + which adapter + which vector store + which retriever config.&lt;/p>
&lt;h3 id="5-cost-per-inference-not-per-training">5. Cost per inference, not per training&lt;/h3>
&lt;p>Traditional MLOps: the high cost is training; serving is cheap. You optimise training.&lt;/p>
&lt;p>LLMOps: the high cost is serving (every token costs, every call to the vendor is paid for, the GPUs doing the serving are switched on 24/7). You optimise inference.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: cost accountability per tenant, per agent, per tool. Metrics such as &lt;code>gen_ai.usage.input_tokens&lt;/code> aggregated at customer and product level. Model decisions based on cost per query, not only on quality.&lt;/p>
&lt;h3 id="6-gpu-heavy-infra-with-specific-primitives">6. GPU-heavy infra with specific primitives&lt;/h3>
&lt;p>Traditional MLOps: CPU + some GPU for training. Standard Kubernetes.&lt;/p>
&lt;p>LLMOps: Hopper/Blackwell SXM GPUs, NVLink/NVSwitch, tensor parallel, paged attention, KV cache. Infra that only fits into Kubernetes with primitives such as LeaderWorkerSet, GPU Operator, KEDA with LLM metrics (covered in &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>).&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: the orchestration stack includes specialised operators (OME, vLLM Production Stack, NVIDIA Dynamo, llm-d, see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a>) that traditional MLOps does not contemplate.&lt;/p>
&lt;h3 id="7-rlhf-and-human-feedback-as-a-first-class-citizen">7. RLHF and human feedback as a first-class citizen&lt;/h3>
&lt;p>Traditional MLOps: human feedback means labelling data before training.&lt;/p>
&lt;p>LLMOps: human feedback lives inside the model in production, whether through RLHF of the foundation models (Anthropic, OpenAI), through RLAIF, through DPO, or through explicit user feedback that is fed back into fine-tuning.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: bidirectional production → training pipelines. Datasets grow with real incidents. Model decisions are taken with continuous feedback, not in a training project every N months.&lt;/p>
&lt;h2 id="why-gartner-predicts-50-failures">Why Gartner predicts 50%+ failures&lt;/h2>
&lt;p>&lt;a href="https://medium.com/@sanjeebmeister/the-complete-mlops-llmops-roadmap-for-2026-building-production-grade-ai-systems-bdcca5ed2771">Gartner published that more than 50% of enterprise GenAI deployments will fail before 2026&lt;/a>. The causes are not technical ones about the model but about the system:&lt;/p>
&lt;ul>
&lt;li>Hallucinated outputs from poor grounding: badly designed RAG, poor retrieval, insufficient context.&lt;/li>
&lt;li>Data architectures that are not ready: companies have data in silos, with no stable schemas, with no controlled freshness. Connecting an LLM to this data without a serious pipeline produces erratic answers.&lt;/li>
&lt;li>Lack of structured workflows for prompt-driven systems: teams that treat prompts as code in hardcoded strings, with no versioning, no tests, no gates.&lt;/li>
&lt;/ul>
&lt;p>The conclusion the field draws: &lt;strong>LLMOps is not optional&lt;/strong>. Companies that deploy GenAI without operational discipline fall into one of the three failure modes. Those that apply it (MLflow/W&amp;amp;B for tracking, DVC/lakeFS for data, Langfuse for prompts and evals, KServe or vLLM Production Stack for serving, drift detection in production) are the ones that keep the system working six months after the first release.&lt;/p>
&lt;h2 id="the-six-stage-llmops-pipeline">The six-stage LLMOps pipeline&lt;/h2>
&lt;p>On to the pipeline. The six stages that any serious LLM system goes through, in order:&lt;/p>
&lt;pre tabindex="0">&lt;code>[1. Data] → [2. Tune] → [3. Eval] → [4. Deploy] → [5. Observe] → [6. Retrain]
│
└─→ back to 1
&lt;/code>&lt;/pre>&lt;p>Each stage is an operational domain of its own, with its own tools and traps:&lt;/p>
&lt;h3 id="stage-1--data">Stage 1 — Data&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: ingestion, cleaning, curation, versioning, indexing of the corpus. It is where real projects suffer most, because companies have data in heterogeneous silos.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: extraction from source (CDC over Kafka, batch from data lakes, scraping), cleaning (PII removal, dedup, format), curation (labelling for fine-tuning, golden datasets for eval), versioning (DVC + lakeFS), indexing (embeddings + vector store for RAG).&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: schema drift at the source, undetected PII, poor dedup that injects redundancy into training, a vector store that is not updated.&lt;/p>
&lt;h3 id="stage-2--tune">Stage 2 — Tune&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: preparing the model for your use case. Three modalities (we go deeper into them shortly): fine-tuning, RAG, agent training.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: base model selection, adapter preparation (LoRA, QLoRA), training loop with continuous eval, hyperparameter sweep (Optuna, W&amp;amp;B Sweeps), saving the checkpoint.&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: catastrophic forgetting if the fine-tuning is very aggressive, overfitting to the golden dataset, no independent validation set.&lt;/p>
&lt;h3 id="stage-3--eval">Stage 3 — Eval&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: validating that the model + adapters + RAG configuration is acceptable before promoting. Covered in &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: running an eval framework (DeepEval, Promptfoo, Ragas) against the golden dataset, judge LLM evaluations, human review over a sample, gates with thresholds.&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: a golden dataset that ages, an uncalibrated judge, evals that pass in CI but fail in production because of distribution shift.&lt;/p>
&lt;h3 id="stage-4--deploy">Stage 4 — Deploy&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: going from &amp;ldquo;the model evaluated well&amp;rdquo; to &amp;ldquo;the model serves real traffic&amp;rdquo;. Covered in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: serving with vLLM/SGLang/TRT-LLM, runtime configuration, gradual rollout (canary, shadow, blue-green), routing between models (LiteLLM, OpenRouter, LangChain routers).&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: a naive rolling update that cuts sessions, autoscaling by CPU% that does not respond to LLM metrics (covered), a new model that performs worse in production than in eval.&lt;/p>
&lt;h3 id="stage-5--observe">Stage 5 — Observe&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: seeing what is happening in real time. Covered in the whole post-tracing series and the eBPF series.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: tracing (Langfuse, LangSmith, Phoenix, OpenLLMetry), metrics (TTFT, TPOT, queue depth, cost per query), active guardrails (NeMo, Llama Guard), drift detection (Evidently, NannyML, WhyLabs).&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: cardinality explosion in metrics, batch evals with no tail-sampling over real traces, drift that is ignored until an incident makes it real.&lt;/p>
&lt;h3 id="stage-6--retrain">Stage 6 — Retrain&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: closing the loop. Production feedback (incidents, worst-scoring cases, detected drift) generates new data to go back to stage 1.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: extraction of problematic logs, human labelling of the sample, incorporation into the golden dataset, re-fine-tuning if applicable, decision on a new release.&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: an &amp;ldquo;open&amp;rdquo; loop where production never informs the dataset, human feedback that gets lost, lack of a clear retrain cadence.&lt;/p>
&lt;h2 id="the-three-modalities-of-preparing-the-model">The three modalities of &amp;ldquo;preparing the model&amp;rdquo;&lt;/h2>
&lt;p>Stage 2 (Tune) is where there is most confusion. In 2026 three modalities coexist, each solving a different problem:&lt;/p>
&lt;h3 id="fine-tuning">Fine-tuning&lt;/h3>
&lt;p>&lt;strong>What it does&lt;/strong>: modifying the model&amp;rsquo;s weights (or those of a LoRA/QLoRA adapter on top) so that it learns specific behaviour patterns: tone, output structure, idiomatic decisions of the domain.&lt;/p>
&lt;p>&lt;strong>When&lt;/strong>: when your main failure is behavioural inconsistency between calls. The model sometimes answers formally, sometimes not; sometimes it structures the JSON, sometimes not; sometimes it follows the company&amp;rsquo;s conventions, sometimes it invents them. Fine-tuning stabilises it.&lt;/p>
&lt;p>&lt;strong>When NOT&lt;/strong>: when what you need is up-to-date knowledge. Fine-tuning fixes knowledge in frozen weights; the day after the fine-tuning, the model knows nothing new.&lt;/p>
&lt;h3 id="rag-retrieval-augmented-generation">RAG (Retrieval-Augmented Generation)&lt;/h3>
&lt;p>&lt;strong>What it does&lt;/strong>: leaving the model intact and, on each call, retrieving fresh context from a knowledge base (vector store + lexical search, typically) and passing it to the model so that it answers on that basis.&lt;/p>
&lt;p>&lt;strong>When&lt;/strong>: when the knowledge you need is dynamic or very large. Documentation that changes, a product catalogue that is updated, an internal knowledge base that grows.&lt;/p>
&lt;p>&lt;strong>When NOT&lt;/strong>: when the problem is behavioural (RAG does not teach the model how to behave, it only gives it information). Or when retrieval is so noisy that the context arriving is worse than nothing.&lt;/p>
&lt;h3 id="agent-training">Agent training&lt;/h3>
&lt;p>&lt;strong>What it does&lt;/strong>: going beyond conventional fine-tuning with Reinforcement Learning techniques. RFT (OpenAI&amp;rsquo;s Reinforcement Fine-Tuning), classic RLHF, RLAIF (with AI feedback), DPO (Direct Preference Optimization) over datasets of (good, bad) pairs.&lt;/p>
&lt;p>&lt;strong>When&lt;/strong>: when the model needs to learn complex multistep trajectories: when to choose each tool, how to decompose a task, when to ask the user for confirmation. It is what is turning Claude, Gemini and GPT into agents capable of long tasks.&lt;/p>
&lt;p>&lt;strong>When NOT&lt;/strong>: when your case is simple chat or RAG. It is overkill, expensive and complicated for problems that the previous modalities solve.&lt;/p>
&lt;h3 id="decision-matrix">Decision matrix&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Observed problem&lt;/th>
&lt;th>Modality&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Inconsistent answers in tone/structure&lt;/td>
&lt;td>Fine-tuning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model makes things up (hallucinates)&lt;/td>
&lt;td>RAG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Out-of-date knowledge (&amp;gt;1 year)&lt;/td>
&lt;td>RAG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model chooses tools badly&lt;/td>
&lt;td>Agent training (RLAIF/RFT)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mixed behaviour + knowledge&lt;/td>
&lt;td>Hybrid (fine-tune + RAG)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-step trajectory fails&lt;/td>
&lt;td>Agent training&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A specific language/regional style&lt;/td>
&lt;td>Fine-tuning&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="the-2026-verdict-hybrid-is-the-default">The 2026 verdict: hybrid is the default&lt;/h3>
&lt;p>&lt;a href="https://www.scalacode.com/blog/rag-vs-fine-tuning/">Multiple reports&lt;/a> agree that in 2025-2026, &lt;strong>around 60% of production projects use hybrid&lt;/strong>: fine-tuning for behaviour + RAG for knowledge. The key insight:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Fine-tune for behaviour (brand voice, decision protocol, output structure); use RAG for volatile knowledge you need the model to cite. Do not force a single tool to do both jobs.&lt;/strong>&lt;/p>
&lt;/blockquote>
&lt;p>A practical observation: the biggest quality improvements of 2025-2026 come from better reranking in RAG (cross-encoders), not from better embeddings. Rerankers add 15-35% of quality with little complexity.&lt;/p>
&lt;p>On cost: combined fine-tuning + RAG tends to be 30-50% cheaper than pure RAG with frontier models at high volume, because the fine-tuned model can be smaller and cheaper while keeping equivalent quality.&lt;/p>
&lt;h2 id="the-2026-tool-landscape">The 2026 tool landscape&lt;/h2>
&lt;p>On to the concrete pieces, grouped by function. The field has matured enough for each piece to have 2-3 reasonable options and a couple of leaders.&lt;/p>
&lt;h3 id="experiment-tracking-and-model-registry">Experiment tracking and model registry&lt;/h3>
&lt;p>&lt;a href="https://mlflow.org/">MLflow&lt;/a> remains the de facto standard, now with LLM-specific traction. MLflow 3 was published in June 2025; version 3.10.1 (March 2026) added:&lt;/p>
&lt;ul>
&lt;li>A GenAI Overview dashboard with ready-made metrics for LLM apps.&lt;/li>
&lt;li>Multi-workspace support for large teams.&lt;/li>
&lt;li>Cost tracking in traces (gen_ai.usage.* aggregated per experiment).&lt;/li>
&lt;li>MemAlign: a new specific eval algorithm.&lt;/li>
&lt;li>Integrated native OpenTelemetry tracing.&lt;/li>
&lt;li>First-class support for LangChain, LlamaIndex, AutoGen as frameworks.&lt;/li>
&lt;/ul>
&lt;p>MLflow treats prompts and agents as first-class citizens alongside classic models. It is the biggest change relative to MLflow 2.x.&lt;/p>
&lt;p>&lt;a href="https://wandb.ai/">Weights &amp;amp; Biases (W&amp;amp;B)&lt;/a> with its LLM-specific product Weave offers tracing + eval + debug with a very polished UI. More commercial, less self-host friendly, but excellent UX.&lt;/p>
&lt;p>&lt;a href="https://www.zenml.io/">ZenML&lt;/a> is the piece that most cleanly integrates &amp;ldquo;classic MLOps + emerging LLMOps&amp;rdquo; into a single framework. Its automatic artifact versioning captures prompt templates, retrieval chunks and agent conversation histories with no extra work. Open-source. The most complete unification option that exists.&lt;/p>
&lt;h3 id="dataset-versioning">Dataset versioning&lt;/h3>
&lt;p>&lt;a href="https://dvc.org/">DVC&lt;/a> remains the OSS standard. It extends Git to large files and pipelines. Important news from November 2025: &lt;a href="https://medium.com/the-modern-scientist/reproducible-ai-versioning-models-prompts-and-data-96dd0337af65">lakeFS acquired DVC&lt;/a>, consolidating the two OSS data versioning projects under one organisation. The combined roadmap is oriented towards LLM training and RAG datalakes specifically.&lt;/p>
&lt;p>&lt;strong>Typical pattern&lt;/strong>: Git for code + DVC for data/models + MLflow or W&amp;amp;B for experiment tracking + registry. Few teams use just one; the combination is what covers the cycle. Detailed in the &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">dedicated post on data versioning for LLMOps&lt;/a> — the four artifacts to version separately, schema contracts, end-to-end lineage, and a golden eval set with a strict holdout.&lt;/p>
&lt;h3 id="prompt-versioning-and-observability">Prompt versioning and observability&lt;/h3>
&lt;p>Covered in depth in the &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight post&lt;/a> where we go deeper into Langfuse as the OSS reference. Summary here:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://langfuse.com/">Langfuse&lt;/a>: MIT, self-host, prompt management with v1/v2/v3 versioning + labels + cache + linkage with traces.&lt;/li>
&lt;li>&lt;a href="https://www.langchain.com/langsmith">LangSmith&lt;/a>: if your stack is LangChain.&lt;/li>
&lt;li>&lt;a href="https://phoenix.arize.com/">Arize Phoenix&lt;/a>: ELv2, OTel-native.&lt;/li>
&lt;/ul>
&lt;h3 id="pipeline-orchestration">Pipeline orchestration&lt;/h3>
&lt;p>For the LLMOps pipeline steps, the dominant options:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.kubeflow.org/docs/components/pipelines/">Kubeflow Pipelines&lt;/a>: the K8s-native standard. KServe (the serving part of Kubeflow) has its vLLM runtime upgraded to v0.8.1+ with support for reasoning models, tool calling, embeddings, reranking, Llama 4 and Qwen 3.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/">ZenML&lt;/a>: already mentioned; also a pipeline orchestrator.&lt;/li>
&lt;li>&lt;a href="https://metaflow.org/">Metaflow&lt;/a> (Netflix-originated): Python-first pipelines, less LLM-specific but workable.&lt;/li>
&lt;li>&lt;a href="https://argoproj.github.io/workflows/">Argo Workflows&lt;/a>: a pure K8s OSS alternative.&lt;/li>
&lt;li>&lt;a href="https://flyte.org/">Flyte&lt;/a>: Kubernetes-native, OSS.&lt;/li>
&lt;/ul>
&lt;h3 id="serving">Serving&lt;/h3>
&lt;p>Covered in depth in &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a>. Summary:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/vllm-project/production-stack">vLLM Production Stack&lt;/a>: a curated Helm chart.&lt;/li>
&lt;li>&lt;a href="https://kserve.github.io/website/">KServe vLLM runtime&lt;/a>: K8s-native, vLLM 0.8.1+ with full agentic support.&lt;/li>
&lt;li>&lt;a href="https://www.bentoml.com/">BentoML&lt;/a>: flexible serving, popular in startups for its simplicity.&lt;/li>
&lt;li>&lt;a href="https://developer.nvidia.com/dynamo">NVIDIA Dynamo&lt;/a>: the successor to Triton.&lt;/li>
&lt;li>&lt;a href="https://github.com/llm-d/llm-d">llm-d&lt;/a>: CNCF Sandbox.&lt;/li>
&lt;li>&lt;a href="https://github.com/ome-projects/ome">OME&lt;/a>: an LMSYS operator with native SGLang.&lt;/li>
&lt;/ul>
&lt;h3 id="evals-and-guardrails">Evals and guardrails&lt;/h3>
&lt;p>Covered in &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a>. Ultra-short summary:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Evals CI&lt;/strong>: DeepEval, Promptfoo, Ragas.&lt;/li>
&lt;li>&lt;strong>Evals platform&lt;/strong>: Langfuse, LangSmith, Phoenix, Braintrust.&lt;/li>
&lt;li>&lt;strong>Guardrails&lt;/strong>: NeMo Guardrails, Llama Guard 4, Llama Prompt Guard 2, LLM Guard, Lakera.&lt;/li>
&lt;/ul>
&lt;h3 id="drift-detection-and-observability">Drift detection and observability&lt;/h3>
&lt;p>Covered in the &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">closing eBPF post&lt;/a>. Summary:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Drift&lt;/strong>: Evidently AI, NannyML, WhyLabs.&lt;/li>
&lt;li>&lt;strong>Tracing&lt;/strong>: Langfuse, OpenLLMetry, Phoenix.&lt;/li>
&lt;li>&lt;strong>eBPF&lt;/strong>: AgentSight, Hubble, Tetragon, ProfInfer.&lt;/li>
&lt;/ul>
&lt;h3 id="the-typical-2026-stack-table">The typical 2026 stack table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th>Dominant piece&lt;/th>
&lt;th>Alternatives&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Data ingestion + versioning&lt;/td>
&lt;td>DVC + lakeFS (unified Nov 2025)&lt;/td>
&lt;td>Pachyderm, Quilt&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Vector store / RAG index&lt;/td>
&lt;td>Milvus, Qdrant, pgvector, Weaviate&lt;/td>
&lt;td>LanceDB, Pinecone, Chroma&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Experiment tracking&lt;/td>
&lt;td>MLflow 3.10&lt;/td>
&lt;td>W&amp;amp;B Weave, Neptune&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pipeline orchestration&lt;/td>
&lt;td>Kubeflow + Argo&lt;/td>
&lt;td>ZenML, Metaflow, Flyte&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model registry&lt;/td>
&lt;td>MLflow registry&lt;/td>
&lt;td>W&amp;amp;B Models, KServe ModelMesh&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Prompt versioning&lt;/td>
&lt;td>Langfuse&lt;/td>
&lt;td>LangSmith, MLflow Prompts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Serving&lt;/td>
&lt;td>vLLM Production Stack&lt;/td>
&lt;td>KServe, BentoML, Dynamo, llm-d, OME&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Evals CI&lt;/td>
&lt;td>DeepEval, Ragas&lt;/td>
&lt;td>Promptfoo, OpenAI Evals&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Evals platform&lt;/td>
&lt;td>Langfuse, Phoenix&lt;/td>
&lt;td>LangSmith, Braintrust&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Guardrails&lt;/td>
&lt;td>NeMo + Llama Guard&lt;/td>
&lt;td>LLM Guard, Lakera&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tracing&lt;/td>
&lt;td>OpenLLMetry + Langfuse&lt;/td>
&lt;td>Phoenix, LangSmith&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Drift detection&lt;/td>
&lt;td>Evidently AI&lt;/td>
&lt;td>NannyML, WhyLabs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>eBPF observability&lt;/td>
&lt;td>AgentSight + Tetragon + Hubble&lt;/td>
&lt;td>(new territory, few alternatives)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>13 pieces. No org uses them all; any serious org uses at least six. &lt;strong>This is the current LLMOps stack&lt;/strong>.&lt;/p>
&lt;h2 id="the-operational-reality-nobody-uses-a-single-tool">The operational reality: nobody uses a single tool&lt;/h2>
&lt;p>&lt;a href="https://medium.com/@kanerika/mlflow-vs-kubeflow-vs-w-b-which-mlops-tool-fits-your-stack-b59007460b25">Multiple comparisons&lt;/a> agree on one thing: &lt;strong>the teams that win combine&lt;/strong>. Recurring patterns:&lt;/p>
&lt;ul>
&lt;li>ZenML to orchestrate + MLflow for tracking + KServe for serving: the most popular OSS stack in companies coming from classic MLOps.&lt;/li>
&lt;li>Kubeflow + W&amp;amp;B + BentoML: for teams with a research focus.&lt;/li>
&lt;li>Langfuse + DeepEval + Phoenix + LiteLLM: for pure-LLM teams with no classic MLOps background.&lt;/li>
&lt;li>MLflow + DVC + Argo + KServe: an idiomatic cloud-native stack without additional LLM-specifics (with its limitations).&lt;/li>
&lt;/ul>
&lt;p>The choice depends on the team&amp;rsquo;s background, on the licensing model they can afford, on the level of self-hosting they need, and on which frictions blocked them most in previous projects. &lt;strong>There is no &amp;ldquo;one right answer&amp;rdquo;&lt;/strong>; there is a stable meta-pattern of layers worth covering.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;h3 id="treating-the-prompt-as-text-in-code">Treating the prompt as text in code&lt;/h3>
&lt;p>Hardcoding prompts in strings in the repo. Changing them requires a PR + redeploy. Result: teams that do not iterate on prompts because every change costs hours of pipeline. &lt;strong>Solution&lt;/strong>: externalised prompt management (Langfuse, MLflow Prompts) with versioning, labels, hot-reload.&lt;/p>
&lt;h3 id="skipping-dataset-versioning">Skipping dataset versioning&lt;/h3>
&lt;p>&amp;ldquo;DVC is complicated, we will add it later&amp;rdquo;. Result: two months later, nobody knows which dataset trained which model. Impossible to reproduce incidents. &lt;strong>Solution&lt;/strong>: DVC + lakeFS from day 1, even if only with a small subset.&lt;/p>
&lt;h3 id="mixing-layers-in-the-same-pipeline">Mixing layers in the same pipeline&lt;/h3>
&lt;p>Teams that put ingestion, fine-tuning, eval and deploy into a single giant pipeline. When something fails, the whole pipeline fails. &lt;strong>Solution&lt;/strong>: independent pipelines per stage, with versioned artifacts as the interfaces between them.&lt;/p>
&lt;h3 id="tracking-with-no-structure">Tracking with no structure&lt;/h3>
&lt;p>Logging everything to stdout and &amp;ldquo;we will look at it in CloudWatch later&amp;rdquo;. Result: impossible to correlate, compare, debug. &lt;strong>Solution&lt;/strong>: OTel from day 1 with &lt;code>gen_ai.*&lt;/code> semantic conventions.&lt;/p>
&lt;h3 id="evals-that-block-nothing">Evals that block nothing&lt;/h3>
&lt;p>You have evals, you run them, you look at them, but they do not prevent the deploy if they drop. Eventually it drops gradually and nobody notices. &lt;strong>Solution&lt;/strong>: eval gates in CI/CD that block the merge if critical metrics regress by more than X%.&lt;/p>
&lt;h3 id="no-retrain-cadence">No retrain cadence&lt;/h3>
&lt;p>You launch v1 and never go back to the model. Six months later, drift has degraded it but the team is on other projects. &lt;strong>Solution&lt;/strong>: a formal retrain cadence (monthly, quarterly) tied to the production incident queue.&lt;/p>
&lt;h3 id="invisible-vendor-lock-in">Invisible vendor lock-in&lt;/h3>
&lt;p>You start with the OpenAI API + LangSmith + Pinecone. When you want to self-host, you discover that migrating is a 3-month project. &lt;strong>Solution&lt;/strong>: abstraction layers (LiteLLM, OpenLLMetry) and vendor-neutrality from the start.&lt;/p>
&lt;h2 id="what-is-coming-in-the-following-posts-of-the-series">What is coming in the following posts of the series&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Post 2 — &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over datalakes with Kafka: end-to-end technical architecture&lt;/a>&lt;/strong> — the most hands-on one. Kafka as source-of-truth, Flink CDC, embedding pipelines, continuous indexing in Milvus/Qdrant, a complete example with real numbers and manifests.&lt;/li>
&lt;li>&lt;strong>Post 3 — &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline: global architecture&lt;/a>&lt;/strong> — the master map of the complete system with a reusable &amp;ldquo;you are here&amp;rdquo; SVG for the following posts. A deep dive into each of the six stages (Data, Tune, Eval, Deploy, Observe, Retrain).&lt;/li>
&lt;li>&lt;strong>Post 4 — &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in the ingestion stage&lt;/a>&lt;/strong> — synchronisation patterns (dual-write, outbox + CDC, event-driven), a complete microservices architecture, a Qdrant cluster manifest.&lt;/li>
&lt;li>&lt;strong>Upcoming posts&lt;/strong> — still to be decided: the cluster as a multi-tenant platform, Constitutional AI / alignment runtime, continuous fine-tuning in depth, edge LLMs.&lt;/li>
&lt;li>&lt;strong>Synthesis post&lt;/strong> — &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production, May 2026&lt;/a> — the complete journey of a real request through the six stages and the two cross-cutting components, with cross-links to each dedicated post. It works as a mental map of the blog and as an integrator&amp;rsquo;s guide.&lt;/li>
&lt;li>&lt;strong>Parallel OSS vs hyperscalers catalogue&lt;/strong> — &lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">The parallel catalogue: the six LLMOps stages in OSS and in AWS / GCP / Azure&lt;/a> — for each stage of the pipeline, which tools the blog&amp;rsquo;s reference OSS stack uses and what the cloud equivalents are, with summary tables, identification of real gaps and the multi-tenant chatbot ported to an AWS stack component by component. A neutral editorial stance.&lt;/li>
&lt;li>&lt;strong>OSS catalogue entry by entry&lt;/strong> — &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: entry by entry&lt;/a> — the zoom in on the open source side: ~150 words of description per core tool (vLLM, Langfuse, DVC, Qdrant, Airflow, NeMo Guardrails, Presidio…), licence and governance, a decision matrix per stage, a diagram of the connected OSS stack and a master table of licences and EE / SaaS offerings.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>LLMOps vs MLOps:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/@sanjeebmeister/the-complete-mlops-llmops-roadmap-for-2026-building-production-grade-ai-systems-bdcca5ed2771">The Complete MLOps/LLMOps Roadmap for 2026 (Sanjeeb Panda)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.iamraghuveer.com/posts/mlops-vs-llmops-what-changes/">MLOps vs LLMOps: What Changes (Raghuveer)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://hyscaler.com/insights/mlops-in-2026-guide/">MLOps in 2026: Architecture, Trends &amp;amp; Strategy (Hyscaler)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.ideas2it.com/blogs/llmops-vs-mlops-key-differences-and-evolution">LLMOps vs MLOps: Differences and Evolution (Ideas2IT)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://dev.to/apprecode/llmops-vs-mlops-whats-different-whats-the-same-and-how-to-run-both-in-production-2o52">LLMOps vs MLOps in production (DEV/Apprecode)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Tools:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mlflow.org/">MLflow&lt;/a> — registry + tracking + serving.&lt;/li>
&lt;li>&lt;a href="https://wandb.ai/site/weave">Weights &amp;amp; Biases Weave&lt;/a> — LLM tracing.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/">ZenML&lt;/a> — pipeline orchestration MLOps + LLMOps.&lt;/li>
&lt;li>&lt;a href="https://www.kubeflow.org/">Kubeflow&lt;/a> — K8s-native MLOps.&lt;/li>
&lt;li>&lt;a href="https://kserve.github.io/website/">KServe&lt;/a> — model serving K8s.&lt;/li>
&lt;li>&lt;a href="https://www.bentoml.com/">BentoML&lt;/a> — flexible serving.&lt;/li>
&lt;li>&lt;a href="https://metaflow.org/">Metaflow&lt;/a> — Netflix&amp;rsquo;s pipelines.&lt;/li>
&lt;li>&lt;a href="https://dvc.org/">DVC&lt;/a> — dataset versioning.&lt;/li>
&lt;li>&lt;a href="https://lakefs.io/">lakeFS&lt;/a> — enterprise data versioning, acquired DVC in Nov 2025.&lt;/li>
&lt;/ul>
&lt;p>2026 comparisons:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/@kanerika/mlflow-vs-kubeflow-vs-w-b-which-mlops-tool-fits-your-stack-b59007460b25">MLflow vs Kubeflow vs W&amp;amp;B (Kanerika)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/blog/mlflow-alternatives">9 MLflow alternatives tested (ZenML)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/blog/metaflow-vs-kubeflow">Metaflow vs Kubeflow vs ZenML (ZenML)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/blog/mlops-tools">12 Best MLOps Tools for Agentic AI (ZenML)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.spheron.network/blog/mlops-pipeline-gpu-cloud-kubeflow-zenml-metaflow-2026/">MLOps Pipeline on GPU Cloud 2026 (Spheron)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://northflank.com/blog/top-7-kubeflow-alternatives">Top 7 Kubeflow alternatives 2026 (Northflank)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.sganalytics.com/blog/mlops-tools/">Top 20 MLOps Tools 2026 (SG Analytics)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>RAG vs Fine-Tuning:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.scalacode.com/blog/rag-vs-fine-tuning/">RAG Vs Fine-Tuning In 2026 (ScalaCode)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.arxiv.org/pdf/2510.01375">Fine-Tuning with RAG (ICLR 2026, arxiv)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://dev.to/tyson_cung/rag-vs-fine-tuning-what-actually-works-in-production-2026-20jg">RAG vs Fine-Tuning — What Actually Works in Production 2026 (DEV)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.kapa.ai/blog/how-to-build-a-rag-pipeline-from-scratch-in-2026">How to Build a RAG Pipeline from Scratch in 2026 (kapa.ai)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Cross-references (the three previous series):&lt;/p>
&lt;ul>
&lt;li>LLM inference series: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on K8s&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a>.&lt;/li>
&lt;li>eBPF series: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a>.&lt;/li>
&lt;li>Post-tracing series: &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/llm-guardrails-safety-four-lines-of-defence/">Guardrails&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><item><title>MCP from the inside and its deep observability: the LSP of AI agents and how to see everything with OpenTelemetry</title><link>https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/</link><pubDate>Wed, 20 May 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;a href="https://modelcontextprotocol.io/">Model Context Protocol (MCP)&lt;/a> is the standard Anthropic published at the end of 2024 and which by 2026 has become &lt;strong>the dominant protocol for connecting AI agents to external tools and data&lt;/strong>. Its value, the reason the whole industry adopted it in under 18 months, is that it &lt;strong>solves a combinatorial problem&lt;/strong>: before MCP, integrating M AI apps with N tools required M×N ad-hoc integrations; with MCP, M + N. It is the same move the &lt;a href="https://microsoft.github.io/language-server-protocol/">Language Server Protocol&lt;/a> made in 2016 for code editors. The architecture is three well-defined roles, &lt;strong>Host&lt;/strong> (the AI app), &lt;strong>Client&lt;/strong> (the connection, one per server) and &lt;strong>Server&lt;/strong> (the piece that exposes capabilities); the primitives are six, three on the server side (Tools, Resources, Prompts) and three on the client side (Sampling, Roots, Elicitation); the protocol is JSON-RPC over two transports, stdio for local processes and Streamable HTTP for remote. The operational challenge appears when there are 10-20 MCP servers running simultaneously, each with several tools, connected to an agent that chains multistep calls: &lt;strong>observing what happens, where things fail, how much each tool costs, which tenant invokes what&lt;/strong> becomes critical. The ecosystem&amp;rsquo;s answer in 2026: the new &lt;strong>OpenTelemetry GenAI semantic conventions for MCP&lt;/strong> (already stable), trace context propagation via &lt;code>params._meta&lt;/code> (because JSON-RPC does not bring it natively), FastMCP with built-in OTel instrumentation, MCP Gateways as a centralised layer (Traefik Hub, MintMCP, OpenObserve), and MCP Inspector for interactive debugging. This article walks the architecture from the outside in, puts each concept in its exact place, and goes down to the detail of observability: traces, RED metrics, real use cases and pitfalls.&lt;/p>
&lt;blockquote>
&lt;p>This is the &lt;strong>third post in the post-tracing series&lt;/strong>. Previous posts: &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a>. Here we go down to the protocol that connects agents to tools, and how to see it in production.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-master-analogy-in-three-versions">The master analogy (in three versions)&lt;/h2>
&lt;p>MCP is a communication protocol. Like any protocol, it is best understood with the right analogy. I am going to give you three, because each one lights up a different facet and the combination leaves you understanding it better than any technical definition.&lt;/p>
&lt;h3 id="version-1--the-usb-c-of-ai-apps-the-official-one">Version 1 — The USB-C of AI apps (the official one)&lt;/h3>
&lt;p>This is the analogy Anthropic adopted when presenting it. Before USB-C, every electronic device had its own connector. Your phone had microUSB or Lightning, your laptop a proprietary power port, your headphones a 3.5mm jack, your external disk USB-A at one end and mini-USB at the other. The result: three boxes full of specific cables that got lost, none of them good for two things, and buying a new device meant buying new accessories.&lt;/p>
&lt;p>USB-C changed that. A single physical connector that many protocols cross: data (USB 3, USB 4, Thunderbolt), video (DisplayPort), power (Power Delivery), audio. You connect anything to anything and it works; the protocols negotiate above.&lt;/p>
&lt;p>MCP plays the same role for AI apps. Before MCP, every application that wanted to integrate tools with an LLM, Claude Desktop, Cursor, Continue, in-house custom agents, invented its own way of doing it. Every tool vendor had to write N different integrations, one per app. The result: massive fragmentation, a lot of duplicated code, integrations that broke when an app changed its internal API.&lt;/p>
&lt;p>With MCP, the connector is one: any app that speaks MCP can use any MCP tool. Just as your USB-C talks to printers, monitors and disks without the printer &amp;ldquo;knowing&amp;rdquo; whether the cable is connected to a Mac or to a Linux box.&lt;/p>
&lt;h3 id="version-2--the-lsp-of-code-editors-the-most-technically-precise">Version 2 — The LSP of code editors (the most technically precise)&lt;/h3>
&lt;p>This is my favourite because the analogy is structurally identical, not just metaphorical.&lt;/p>
&lt;p>Until 2016, if you wanted your code editor to support a new language, Rust, Go, TypeScript, somebody had to write a specific plugin for your particular editor. VSCode had its Rust plugin, IntelliJ a different one, Vim another, Emacs another. Every decent feature (go-to-definition, autocompletion, refactoring) was an implementation duplicated N times. &lt;strong>M editors × N languages = M·N integrations&lt;/strong>.&lt;/p>
&lt;p>In 2016 Microsoft proposed the &lt;strong>Language Server Protocol (LSP)&lt;/strong>: each language implements a single &amp;ldquo;language server&amp;rdquo; (a process that understands that language); each editor implements a single LSP client; when you work with Rust code in VSCode, VSCode launches rust-analyzer as a subprocess and talks LSP to it over stdio. Any LSP editor + any LSP server = it works. &lt;strong>M + N&lt;/strong>.&lt;/p>
&lt;p>MCP is literally this pattern, transferred from &amp;ldquo;editor + language server&amp;rdquo; to &amp;ldquo;AI app + tool provider&amp;rdquo;. And it shares even the technical detail: both pass &lt;strong>JSON-RPC over stdio&lt;/strong> (among other transports). When Anthropic designed MCP, they looked at LSP. Anyone coming from the world of editors and IDEs will find MCP familiar.&lt;/p>
&lt;h3 id="version-3--the-operating-system-driver-the-operational-one">Version 3 — The operating system driver (the operational one)&lt;/h3>
&lt;p>Finally, an analogy that helps you understand what a specific MCP server does.&lt;/p>
&lt;p>An operating system does not know directly how to talk to your particular HP LaserJet printer. What it knows is a generic interface: &amp;ldquo;print document&amp;rdquo;, &amp;ldquo;query status&amp;rdquo;, &amp;ldquo;cancel job&amp;rdquo;. The printer driver is the piece that translates that generic interface into the proprietary commands of your specific printer.&lt;/p>
&lt;p>An MCP server does exactly the same:&lt;/p>
&lt;ul>
&lt;li>Your AI agent knows a generic interface: invoke a tool with a defined schema, read a resource by URI, ask for a prompt template by name.&lt;/li>
&lt;li>The &lt;strong>MCP server&lt;/strong> is the driver: it translates those generic operations into the concrete APIs of the underlying system, your PostgreSQL database, your filesystem, your GitHub API, your Stripe.&lt;/li>
&lt;/ul>
&lt;p>This leaves the AI agent free from knowing how it authenticates with GitHub, what exact SQL PostgreSQL uses, what endpoints Stripe has. It speaks MCP; the server takes care of the details.&lt;/p>
&lt;p>With the three analogies combined: &lt;strong>MCP is the layer between the LLM and the world, a standard USB-C implemented like LSP in JSON-RPC, with each server acting as a driver for one concrete underlying system&lt;/strong>.&lt;/p>
&lt;h2 id="what-concrete-problem-mcp-solves">What concrete problem MCP solves&lt;/h2>
&lt;p>Before going down to the architecture, it is worth pinning down the specific problem MCP solves, because without that many design decisions look arbitrary.&lt;/p>
&lt;p>The problem is &lt;strong>the quadratic cost of integrations&lt;/strong>.&lt;/p>
&lt;p>Imagine you have M applications that use LLMs (Claude Desktop, Cursor, Continue, ChatGPT Desktop, your own custom agent, and so on) and N external tools those LLMs could use (filesystem, GitHub, Slack, PostgreSQL, Jira, Notion, and so on). Without a standard:&lt;/p>
&lt;ul>
&lt;li>Each (application, tool) pair requires a specific integration.&lt;/li>
&lt;li>Every time the application changes its internal API, N integrations have to be updated.&lt;/li>
&lt;li>Every time the tool changes its API, M have to be updated.&lt;/li>
&lt;li>For your new tool to be adopted, you have to write M integrations.&lt;/li>
&lt;li>For your new application to support the ecosystem, you have to write N.&lt;/li>
&lt;/ul>
&lt;p>The real result in 2023-2024: massive fragmentation. OpenAI&amp;rsquo;s function calling was not compatible with Anthropic&amp;rsquo;s tool use; every framework (LangChain, LlamaIndex, dspy) had its own wrapper; Claude Desktop plugins did not work in Cursor; and so on.&lt;/p>
&lt;p>MCP breaks the quadratic. Each application implements the protocol once; each tool implements the protocol once; any pair works. M + N.&lt;/p>
&lt;p>It is exactly what happened with USB-C, with LSP, with SQL (before there were proprietary APIs per database), with POSIX (before there were proprietary APIs per operating system). The pattern repeats because it always solves the same kind of problem.&lt;/p>
&lt;h2 id="the-architecture-three-roles-clearly-placed">The architecture: three roles, clearly placed&lt;/h2>
&lt;p>We are going to pin down where each thing lives, because mixing up the roles is the number one source of confusion in MCP.&lt;/p>
&lt;div class="diagram" style="max-width:720px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 720 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="MCP architecture: Host, Client, Server">
&lt;style>.title{font:600 13px sans-serif;fill:#222}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#555}.box{stroke:#444;stroke-width:1.4}.host{fill:#ffe9d6}.llm{fill:#ffd6d6}.client{fill:#d6eaff}.server{fill:#d9f5d6}.sys{fill:#eee;stroke-dasharray:4 2}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#mh)}.bidi{stroke:#888;stroke-width:1.2;fill:none}&lt;/style>
&lt;defs>&lt;marker id="mh" 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="360" y="20" text-anchor="middle" class="title">MCP architecture: where each piece lives&lt;/text>
&lt;rect x="30" y="40" width="280" height="280" rx="8" class="box host"/>
&lt;text x="170" y="60" text-anchor="middle" class="lbl">HOST&lt;/text>
&lt;text x="170" y="76" text-anchor="middle" class="sm">AI app: Claude Desktop, Cursor, own agent&lt;/text>
&lt;rect x="55" y="95" width="230" height="50" rx="6" class="box llm"/>
&lt;text x="170" y="116" text-anchor="middle" class="lbl">LLM (reasoning engine)&lt;/text>
&lt;text x="170" y="132" text-anchor="middle" class="sm">decides which tools to call, what to read&lt;/text>
&lt;rect x="55" y="160" width="100" height="36" rx="6" class="box client"/>
&lt;text x="105" y="183" text-anchor="middle" class="lbl">Client 1&lt;/text>
&lt;rect x="160" y="160" width="120" height="36" rx="6" class="box client"/>
&lt;text x="220" y="183" text-anchor="middle" class="lbl">Client 2&lt;/text>
&lt;rect x="55" y="210" width="100" height="36" rx="6" class="box client"/>
&lt;text x="105" y="233" text-anchor="middle" class="lbl">Client 3&lt;/text>
&lt;rect x="160" y="210" width="120" height="36" rx="6" class="box client"/>
&lt;text x="220" y="233" text-anchor="middle" class="lbl">Client N&lt;/text>
&lt;text x="170" y="275" text-anchor="middle" class="sm">one MCP client per connected server&lt;/text>
&lt;text x="170" y="295" text-anchor="middle" class="sm">each client is a 1:1 connection&lt;/text>
&lt;rect x="380" y="60" width="200" height="70" rx="6" class="box server"/>
&lt;text x="480" y="82" text-anchor="middle" class="lbl">Server: filesystem-mcp&lt;/text>
&lt;text x="480" y="100" text-anchor="middle" class="sm">stdio (local process)&lt;/text>
&lt;text x="480" y="116" text-anchor="middle" class="sm">tools: read, write, list, search&lt;/text>
&lt;rect x="380" y="140" width="200" height="70" rx="6" class="box server"/>
&lt;text x="480" y="162" text-anchor="middle" class="lbl">Server: github-mcp&lt;/text>
&lt;text x="480" y="180" text-anchor="middle" class="sm">Streamable HTTP (remote)&lt;/text>
&lt;text x="480" y="196" text-anchor="middle" class="sm">tools: create_issue, get_pr, ...&lt;/text>
&lt;rect x="380" y="220" width="200" height="70" rx="6" class="box server"/>
&lt;text x="480" y="242" text-anchor="middle" class="lbl">Server: postgres-mcp&lt;/text>
&lt;text x="480" y="260" text-anchor="middle" class="sm">stdio (local process)&lt;/text>
&lt;text x="480" y="276" text-anchor="middle" class="sm">tools: query, schema; resources: tables&lt;/text>
&lt;rect x="610" y="60" width="80" height="70" rx="6" class="box sys"/>
&lt;text x="650" y="92" text-anchor="middle" class="sm">local FS&lt;/text>
&lt;text x="650" y="108" text-anchor="middle" class="sm">↕&lt;/text>
&lt;rect x="610" y="140" width="80" height="70" rx="6" class="box sys"/>
&lt;text x="650" y="172" text-anchor="middle" class="sm">GitHub API&lt;/text>
&lt;text x="650" y="188" text-anchor="middle" class="sm">↕&lt;/text>
&lt;rect x="610" y="220" width="80" height="70" rx="6" class="box sys"/>
&lt;text x="650" y="252" text-anchor="middle" class="sm">PostgreSQL&lt;/text>
&lt;text x="650" y="268" text-anchor="middle" class="sm">↕&lt;/text>
&lt;path class="bidi" d="M155,178 L380,95"/>
&lt;path class="bidi" d="M280,178 L380,175"/>
&lt;path class="bidi" d="M155,228 L380,255"/>
&lt;path class="bidi" d="M580,95 L610,95"/>
&lt;path class="bidi" d="M580,175 L610,175"/>
&lt;path class="bidi" d="M580,255 L610,255"/>
&lt;text x="170" y="340" text-anchor="middle" class="sm">the clients inside the host speak MCP to the servers; the servers translate to the system&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Three roles. Let us pin down what each one does and where it physically lives.&lt;/p>
&lt;h3 id="host-the-ai-application">Host: the AI application&lt;/h3>
&lt;p>The &lt;strong>Host&lt;/strong> is the application the user opens. Claude Desktop, Cursor, Continue, ChatGPT Desktop, a custom agent your team builds, a VSCode extension. What the user perceives as &amp;ldquo;the product&amp;rdquo;.&lt;/p>
&lt;p>The Host is responsible for:&lt;/p>
&lt;ul>
&lt;li>Deciding which MCP servers to connect (configured by the user in a file or via the UI).&lt;/li>
&lt;li>Launching or connecting to each MCP server.&lt;/li>
&lt;li>Creating one MCP Client per server (it is 1:1, they are not shared).&lt;/li>
&lt;li>Embedding the &lt;strong>LLM&lt;/strong> (or calling it via API) that takes the decisions about which tools to use.&lt;/li>
&lt;li>Mediating the user&amp;rsquo;s authorisation for sensitive actions (showing the human &amp;ldquo;the agent wants to run tool X, do you allow it?&amp;rdquo;).&lt;/li>
&lt;/ul>
&lt;p>Important: &lt;strong>the LLM lives inside the Host&lt;/strong>, not in the servers. The servers are dumb; they execute operations when asked. The reasoning (&amp;ldquo;should I call this tool now?&amp;rdquo;) lives in the host&amp;rsquo;s LLM.&lt;/p>
&lt;h3 id="client-the-connection-one-per-server">Client: the connection, one per server&lt;/h3>
&lt;p>An &lt;strong>MCP Client&lt;/strong> is a specific connection between the Host and a Server. If your Host has 5 MCP servers configured, it has 5 clients, not one shared. Each client:&lt;/p>
&lt;ul>
&lt;li>Maintains its socket or stdio pipe with the server.&lt;/li>
&lt;li>Negotiates capabilities in the initial handshake (which protocol version, which primitives both support).&lt;/li>
&lt;li>Serialises JSON-RPC requests to the server and deserialises responses.&lt;/li>
&lt;li>Is the point where the Host invokes operations on the server.&lt;/li>
&lt;/ul>
&lt;p>The 1:1 client-server separation matters because it lets each server have its own session state, its specific permissions and its independent authenticated context. There is no multiplexing in the client.&lt;/p>
&lt;h3 id="server-the-piece-that-exposes-capabilities">Server: the piece that exposes capabilities&lt;/h3>
&lt;p>The &lt;strong>MCP Server&lt;/strong> is the piece that implements the tool-provider side of the protocol. It receives JSON-RPC from the client, processes it, executes the action against the underlying system and returns a response.&lt;/p>
&lt;p>There are two flavours physically:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Local server&lt;/strong>: it starts as a subprocess of the Host and communicates over stdio. Its lifecycle is the Host&amp;rsquo;s (when you close Claude Desktop, the local servers die). Typical model: your Host launches &lt;code>node filesystem-mcp-server.js&lt;/code> as a child.&lt;/li>
&lt;li>&lt;strong>Remote server&lt;/strong>: it runs as an independent service, reachable over HTTP. Multi-tenant, authenticated, scalable. Typical model: a company publishes &lt;code>https://mcp.acme.com/v1&lt;/code> and many hosts connect to it.&lt;/li>
&lt;/ul>
&lt;p>This difference has enormous consequences for observability (we will come back to it shortly).&lt;/p>
&lt;h3 id="summary-of-where-each-thing-lives">Summary of where each thing lives&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Lives in&lt;/th>
&lt;th>How many there are&lt;/th>
&lt;th>Speaks what with whom&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Host&lt;/td>
&lt;td>User&amp;rsquo;s machine&lt;/td>
&lt;td>1 (the open app)&lt;/td>
&lt;td>UI with the user; launches clients&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LLM&lt;/td>
&lt;td>Embedded in the Host (or cloud API)&lt;/td>
&lt;td>1 (the main one)&lt;/td>
&lt;td>Reasons; asks for tools&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Client&lt;/td>
&lt;td>Host&lt;/td>
&lt;td>1 per server&lt;/td>
&lt;td>JSON-RPC with its server&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Local server&lt;/td>
&lt;td>Subprocess of the Host&lt;/td>
&lt;td>1 per local integration&lt;/td>
&lt;td>stdio with its client&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Remote server&lt;/td>
&lt;td>External service&lt;/td>
&lt;td>1 per service&lt;/td>
&lt;td>HTTP/SSE with its clients&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Underlying system&lt;/td>
&lt;td>External&lt;/td>
&lt;td>Depends&lt;/td>
&lt;td>API/DB/FS, not MCP&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If you get confused in a discussion, come back to this table. The number one source of errors in MCP is saying &amp;ldquo;the server&amp;rdquo; when you mean &amp;ldquo;the host&amp;rdquo;.&lt;/p>
&lt;h2 id="the-two-layers-of-the-protocol">The two layers of the protocol&lt;/h2>
&lt;p>MCP separates the &lt;strong>data layer&lt;/strong> and the &lt;strong>transport layer&lt;/strong>. This separation is what lets the protocol work over local stdio and over remote HTTP without changing anything in the primitives.&lt;/p>
&lt;h3 id="data-layer-json-rpc-with-mcp-extensions">Data Layer: JSON-RPC with MCP extensions&lt;/h3>
&lt;p>The data layer defines the vocabulary of the messages. It is &lt;strong>JSON-RPC 2.0&lt;/strong>. Each message is a JSON with &lt;code>jsonrpc: &amp;quot;2.0&amp;quot;&lt;/code>, a &lt;code>method&lt;/code> (eg &lt;code>tools/call&lt;/code>, &lt;code>resources/read&lt;/code>), &lt;code>params&lt;/code>, and an &lt;code>id&lt;/code> to correlate request with response.&lt;/p>
&lt;p>On top of JSON-RPC, MCP adds:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Lifecycle&lt;/strong>: the initial handshake (&lt;code>initialize&lt;/code>, &lt;code>initialized&lt;/code>) that negotiates capabilities.&lt;/li>
&lt;li>&lt;strong>The primitives&lt;/strong> (next section): &lt;code>tools/*&lt;/code>, &lt;code>resources/*&lt;/code>, &lt;code>prompts/*&lt;/code>, &lt;code>sampling/*&lt;/code> and so on.&lt;/li>
&lt;li>&lt;strong>Notifications&lt;/strong>: messages without a response (eg &lt;code>notifications/cancelled&lt;/code> to abort a tool in progress).&lt;/li>
&lt;li>&lt;strong>Meta-information&lt;/strong>: the &lt;code>params._meta&lt;/code> field carries cross-cutting metadata by convention (trace context, request IDs).&lt;/li>
&lt;/ul>
&lt;h3 id="transport-layer-how-the-messages-move">Transport Layer: how the messages move&lt;/h3>
&lt;p>The transport layer defines how the JSON-RPC messages travel. Two official transports:&lt;/p>
&lt;p>&lt;strong>stdio&lt;/strong>: the client launches the server as a subprocess and they communicate over its stdin/stdout/stderr with JSON-RPC. One message per line, separated by a newline. No network, no TLS handshake, no auth (trust is inherited from the operating system itself: if you launch the subprocess, you trust it). Minimal latency (~100 μs round-trip), maximum bandwidth (memcpy, not a socket).&lt;/p>
&lt;p>Use case: local servers that live on the same machine as the host. Most of the MCP servers you see in public directories are stdio.&lt;/p>
&lt;p>&lt;strong>Streamable HTTP&lt;/strong>: the client sends a POST to an HTTP endpoint on the server; the server responds with JSON, optionally opening a Server-Sent Events stream to send asynchronous notifications or long responses. Auth by bearer token, API key or custom headers.&lt;/p>
&lt;p>Introduced in the November 2025 spec, it replaces the pure SSE transport of earlier versions, which had bidirectionality limitations. Use case: remote servers serving many simultaneous clients, with authentication and multi-tenancy.&lt;/p>
&lt;p>Important: the primitives are the same on both transports. A &lt;code>tools/call&lt;/code> is identical over stdio and over HTTP. The transport is accidental, not fundamental.&lt;/p>
&lt;h2 id="the-six-primitives-placed-in-the-architecture">The six primitives: placed in the architecture&lt;/h2>
&lt;p>Here is the meat. There are six primitives in MCP. They are often confused because several look like they do similar things. The key classification: &lt;strong>three live on the server side&lt;/strong> (the server exposes, the client consumes) and &lt;strong>three on the client side&lt;/strong> (the client exposes, the server consumes).&lt;/p>
&lt;h3 id="server-side-what-the-server-gives-the-host">Server-side: what the server gives the host&lt;/h3>
&lt;p>&lt;strong>Tools&lt;/strong> are actions the server exposes. Each tool has a schema (typed parameters, description) and an implementation. When the host&amp;rsquo;s LLM decides to invoke a tool, the client sends &lt;code>tools/call&lt;/code> to the server, which executes it and returns a result.&lt;/p>
&lt;ul>
&lt;li>Example: the &lt;code>github-mcp&lt;/code> server exposes &lt;code>create_issue(repo, title, body)&lt;/code>. The host&amp;rsquo;s LLM decides &amp;ldquo;I am going to create an issue&amp;rdquo;, calls this tool, github-mcp talks to the GitHub API, returns the issue ID to the LLM.&lt;/li>
&lt;li>Architectural place: &lt;strong>the server exposes them, the LLM consumes them&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Resources&lt;/strong> are contextual data the server exposes, addressable by URI. They are not actions; they are content reads. A resource has a URI (&lt;code>file:///path/to/doc.md&lt;/code>, &lt;code>postgres://table/users&lt;/code>), metadata and an endpoint to read content.&lt;/p>
&lt;ul>
&lt;li>Example: the &lt;code>filesystem-mcp&lt;/code> server exposes the files in the authorised directories as resources. The LLM asks for &lt;code>resources/read&lt;/code> with the URI &lt;code>file:///docs/api.md&lt;/code> and gets the text.&lt;/li>
&lt;li>Architectural place: &lt;strong>the server exposes them, the host reads them (and optionally passes them to the LLM as context)&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>Key difference between Tools and Resources: &lt;strong>Tools are verbs&lt;/strong> (they execute, they modify state, they have side effects); &lt;strong>Resources are nouns&lt;/strong> (they exist, they are read, they are idempotent). If you have something that is &amp;ldquo;search for text in files&amp;rdquo; → probably a Tool (an action). If it is &amp;ldquo;this specific file&amp;rdquo; → a Resource. The distinction matters for auditing and permissions: tools require more control.&lt;/p>
&lt;p>&lt;strong>Prompts&lt;/strong> are parameterised prompt templates the server exposes. The user or the host can invoke them to inject a conversational pattern into the model.&lt;/p>
&lt;ul>
&lt;li>Example: a &lt;code>code-review-mcp&lt;/code> server exposes a prompt &lt;code>review_diff(diff_text, style=&amp;quot;strict&amp;quot;)&lt;/code> that returns a complete, well-written prompt asking the LLM to review code.&lt;/li>
&lt;li>Architectural place: &lt;strong>the server exposes them, the user or the host invokes them, the LLM receives them as input&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>Prompts are the least used of the three primitives; many servers do not even implement them. But they let a team publish good prompts as a reusable library, separate from the agent.&lt;/p>
&lt;h3 id="client-side-what-the-host-gives-the-server">Client-side: what the host gives the server&lt;/h3>
&lt;p>This is where MCP differs from protocols like HTTP REST: the server can also ask things of the host, it is not a one-way street. Three primitives travel in that direction.&lt;/p>
&lt;p>&lt;strong>Sampling&lt;/strong>: the server asks the host to run a generation with its LLM. That is, the server borrows the host&amp;rsquo;s LLM in order to reason.&lt;/p>
&lt;ul>
&lt;li>Example: the &lt;code>search-mcp&lt;/code> server receives a query from the agent, searches its corpus, finds 50 results and needs to summarise them before returning. Instead of having its own LLM, it sends a &lt;code>sampling/createMessage&lt;/code> to the client; the host passes this to its LLM, runs the generation with the user&amp;rsquo;s permissions, and returns the summary to the server.&lt;/li>
&lt;li>Architectural place: &lt;strong>the server asks for it, the host (with its LLM and the user&amp;rsquo;s authorisation) fulfils it&lt;/strong>.&lt;/li>
&lt;li>Why it matters: the user controls which model is used, what cost is paid, what permissions apply. The server does not need its own OpenAI API key.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Roots&lt;/strong>: the host tells the server where to look. Roots are URIs (directories, repositories, namespaces) the host authorises the server to explore.&lt;/p>
&lt;ul>
&lt;li>Example: your Claude Desktop starts &lt;code>filesystem-mcp&lt;/code> with roots &lt;code>[file:///Users/me/projects]&lt;/code>. The server knows it must only operate inside that folder, not in &lt;code>/etc/passwd&lt;/code>.&lt;/li>
&lt;li>Architectural place: &lt;strong>the host declares them in the handshake, the server respects them&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Elicitation&lt;/strong>: the server asks the host for additional information from the human user via a structured UI.&lt;/p>
&lt;ul>
&lt;li>Example: the &lt;code>stripe-mcp&lt;/code> server is about to process a refund of €5000. Before executing, it sends an &lt;code>elicitation/createMessage&lt;/code> to the client; the host shows the user &amp;ldquo;Confirm this €5000 refund&amp;rdquo; with a button; when the user confirms, it returns OK to the server, which then proceeds.&lt;/li>
&lt;li>Architectural place: &lt;strong>the server asks, the host shows the user, the user decides, the answer goes back to the server&lt;/strong>.&lt;/li>
&lt;li>It is the key primitive for human-in-the-loop on sensitive actions.&lt;/li>
&lt;/ul>
&lt;h3 id="visualising-the-flow-of-the-six-primitives">Visualising the flow of the six primitives&lt;/h3>
&lt;pre tabindex="0">&lt;code> HOST SERVER
│ │
Server-side ─────┼─────────────────────────────────────┤
│ │
tools/list ──────┼─────── asks what tools exist ──────▶│
│◀────── returns the list ────────────│
│ │
tools/call ──────┼────────── run this tool ───────────▶│
│◀────── result ──────────────────────│
│ │
resources/read ──┼────────── read this URI ───────────▶│
│◀────── content ─────────────────────│
│ │
prompts/get ─────┼─────── give me this prompt ────────▶│
│◀────── compiled prompt ─────────────│
│ │
Client-side ─────┼─────────────────────────────────────┤
│ │
sampling ────────│◀────── I need a generation ─────────│
│── use my LLM ───┐ │
│── returns ──────▼──────────────────▶│
│ │
roots ───────────┼───── declared in the handshake ────▶│
│ │
elicitation ─────│◀────── ask the user X ──────────────│
│── shows UI ────┐ │
│── confirms ────▼───────────────────▶│
&lt;/code>&lt;/pre>&lt;h2 id="json-rpc-in-action-a-concrete-example">JSON-RPC in action: a concrete example&lt;/h2>
&lt;p>To make the theory concrete, here is a real MCP conversation between a client and the &lt;code>filesystem-mcp&lt;/code> server:&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-jsonc" data-lang="jsonc">// 1. Initial handshake (client → server)
{
&amp;#34;jsonrpc&amp;#34;: &amp;#34;2.0&amp;#34;, &amp;#34;id&amp;#34;: 1, &amp;#34;method&amp;#34;: &amp;#34;initialize&amp;#34;,
&amp;#34;params&amp;#34;: {
&amp;#34;protocolVersion&amp;#34;: &amp;#34;2026-03-01&amp;#34;,
&amp;#34;capabilities&amp;#34;: {
&amp;#34;sampling&amp;#34;: {}, // this client supports sampling
&amp;#34;roots&amp;#34;: { &amp;#34;listChanged&amp;#34;: true }
},
&amp;#34;clientInfo&amp;#34;: { &amp;#34;name&amp;#34;: &amp;#34;ClaudeDesktop&amp;#34;, &amp;#34;version&amp;#34;: &amp;#34;1.2.0&amp;#34; }
}
}
// 2. The server responds with its capabilities
{
&amp;#34;jsonrpc&amp;#34;: &amp;#34;2.0&amp;#34;, &amp;#34;id&amp;#34;: 1, &amp;#34;result&amp;#34;: {
&amp;#34;protocolVersion&amp;#34;: &amp;#34;2026-03-01&amp;#34;,
&amp;#34;capabilities&amp;#34;: {
&amp;#34;tools&amp;#34;: { &amp;#34;listChanged&amp;#34;: true },
&amp;#34;resources&amp;#34;: { &amp;#34;subscribe&amp;#34;: true, &amp;#34;listChanged&amp;#34;: true },
&amp;#34;prompts&amp;#34;: {}
},
&amp;#34;serverInfo&amp;#34;: { &amp;#34;name&amp;#34;: &amp;#34;filesystem-mcp&amp;#34;, &amp;#34;version&amp;#34;: &amp;#34;0.5.2&amp;#34; }
}
}
// 3. The client asks for the list of tools
{
&amp;#34;jsonrpc&amp;#34;: &amp;#34;2.0&amp;#34;, &amp;#34;id&amp;#34;: 2, &amp;#34;method&amp;#34;: &amp;#34;tools/list&amp;#34;
}
// 4. The server returns its tools with a schema
{
&amp;#34;jsonrpc&amp;#34;: &amp;#34;2.0&amp;#34;, &amp;#34;id&amp;#34;: 2, &amp;#34;result&amp;#34;: {
&amp;#34;tools&amp;#34;: [
{
&amp;#34;name&amp;#34;: &amp;#34;read_file&amp;#34;,
&amp;#34;description&amp;#34;: &amp;#34;Read a file from the filesystem&amp;#34;,
&amp;#34;inputSchema&amp;#34;: {
&amp;#34;type&amp;#34;: &amp;#34;object&amp;#34;,
&amp;#34;properties&amp;#34;: { &amp;#34;path&amp;#34;: { &amp;#34;type&amp;#34;: &amp;#34;string&amp;#34; } },
&amp;#34;required&amp;#34;: [&amp;#34;path&amp;#34;]
}
},
{ &amp;#34;name&amp;#34;: &amp;#34;write_file&amp;#34;, &amp;#34;description&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;inputSchema&amp;#34;: {} },
{ &amp;#34;name&amp;#34;: &amp;#34;list_directory&amp;#34;, &amp;#34;description&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;inputSchema&amp;#34;: {} }
]
}
}
// 5. The LLM decides to call read_file; the client sends tools/call
{
&amp;#34;jsonrpc&amp;#34;: &amp;#34;2.0&amp;#34;, &amp;#34;id&amp;#34;: 3, &amp;#34;method&amp;#34;: &amp;#34;tools/call&amp;#34;,
&amp;#34;params&amp;#34;: {
&amp;#34;name&amp;#34;: &amp;#34;read_file&amp;#34;,
&amp;#34;arguments&amp;#34;: { &amp;#34;path&amp;#34;: &amp;#34;/Users/me/projects/notes.md&amp;#34; },
&amp;#34;_meta&amp;#34;: { // ← the extension where trace context will go
&amp;#34;traceparent&amp;#34;: &amp;#34;00-abc123...-def456-01&amp;#34;
}
}
}
// 6. The server returns the file content
{
&amp;#34;jsonrpc&amp;#34;: &amp;#34;2.0&amp;#34;, &amp;#34;id&amp;#34;: 3, &amp;#34;result&amp;#34;: {
&amp;#34;content&amp;#34;: [
{ &amp;#34;type&amp;#34;: &amp;#34;text&amp;#34;, &amp;#34;text&amp;#34;: &amp;#34;# My notes\n\n...&amp;#34; }
]
}
}
&lt;/code>&lt;/pre>&lt;p>The important thing to note: &lt;strong>&lt;code>params._meta&lt;/code>&lt;/strong>. That is the bag where MCP conventionally passes cross-cutting metadata, including trace context. We will come back to it shortly.&lt;/p>
&lt;h2 id="the-observability-problem-why-traditional-tracing-is-not-enough">The observability problem: why traditional tracing is not enough&lt;/h2>
&lt;p>So much for theory. Let us go down to the operational problem: in a 2026 production cluster, a typical agent has 5-15 MCP servers connected simultaneously, each with 5-20 tools, and each conversation with the agent can generate dozens of chained tool calls. Without observability, debugging incidents is impossible.&lt;/p>
&lt;p>Why generic tracing (Hubble, OTel without MCP conventions) is not enough:&lt;/p>
&lt;p>&lt;strong>Stdio is not visible on the network&lt;/strong>. Local servers talk over OS pipes. Your Hubble or your Datadog APM see nothing; there are no packets to capture. AgentSight (seen in the &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">previous post in the eBPF series&lt;/a>) with &lt;code>stdiocap&lt;/code> captures it but gives you the raw JSON-RPC, without semantic context (which tool it is, which resource, which prompt).&lt;/p>
&lt;p>&lt;strong>Generic HTTP does not understand MCP either&lt;/strong>. If you trace the HTTP to a remote MCP server without MCP conventions, you see a POST to &lt;code>/v1&lt;/code> with an opaque JSON-RPC body. You lose &amp;ldquo;which tool was invoked&amp;rdquo;, &amp;ldquo;what arguments&amp;rdquo;, &amp;ldquo;was it elicitation or sampling&amp;rdquo;. RED metrics per endpoint are no use to you; you need RED per tool.&lt;/p>
&lt;p>&lt;strong>JSON-RPC does not propagate trace context natively&lt;/strong>. Unlike HTTP (the W3C traceparent header) or gRPC (metadata), JSON-RPC has no standard field for trace context. If you do not propagate it, every call to the server starts a new trace disconnected from the agent&amp;rsquo;s trace.&lt;/p>
&lt;p>&lt;strong>Multistep multi-server is very hard to follow&lt;/strong>. A single user conversation can translate into: 1) a call to github-mcp &lt;code>get_pr&lt;/code>; 2) a call to filesystem-mcp &lt;code>read_file&lt;/code> for several files; 3) a call to the main LLM with all the context; 4) a call to postgres-mcp &lt;code>query&lt;/code>; 5) a call to slack-mcp &lt;code>send_message&lt;/code>. Without propagated trace context, those are five disconnected traces. With propagation, it is one tree.&lt;/p>
&lt;p>The solution: &lt;strong>OpenTelemetry semantic conventions for MCP&lt;/strong>, already stable in 2026.&lt;/p>
&lt;h2 id="opentelemetry-semantic-conventions-for-mcp">OpenTelemetry semantic conventions for MCP&lt;/h2>
&lt;p>The &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/">GenAI MCP semantic conventions&lt;/a> are the set of standardised attributes for MCP-related spans and metrics. They were published as part of the GenAI subgroup of the OpenTelemetry SIG and are the first part of the GenAI semantic conventions to reach stable.&lt;/p>
&lt;h3 id="why-mcp-specific-semantic-conventions">Why MCP-specific semantic conventions&lt;/h3>
&lt;p>Before they existed, teams instrumented MCP with the generic &lt;strong>RPC semantic conventions&lt;/strong> (the ones you would use for gRPC or XML-RPC). It half worked. The MCP-specific conventions add:&lt;/p>
&lt;ul>
&lt;li>Attributes to identify which primitive was executed (&lt;code>mcp.method.name = &amp;quot;tools/call&amp;quot;&lt;/code>).&lt;/li>
&lt;li>Attributes to identify which specific tool/resource/prompt was touched (&lt;code>mcp.tool.name&lt;/code>, &lt;code>mcp.resource.uri&lt;/code>, &lt;code>mcp.prompt.name&lt;/code>).&lt;/li>
&lt;li>Attributes for the bidirectional flow (sampling/elicitation requests from the server to the client).&lt;/li>
&lt;li>Attributes for the handshake (&lt;code>mcp.protocol.version&lt;/code>, &lt;code>mcp.client.name&lt;/code>, &lt;code>mcp.server.name&lt;/code>).&lt;/li>
&lt;li>Standardised RED metrics per tool (&lt;code>mcp.tool.call.duration&lt;/code>, &lt;code>mcp.tool.call.errors&lt;/code>).&lt;/li>
&lt;/ul>
&lt;h3 id="the-canonical-attributes">The canonical attributes&lt;/h3>
&lt;p>The attributes any MCP-aware instrumentation should emit:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Meaning&lt;/th>
&lt;th>Example&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>mcp.method.name&lt;/code>&lt;/td>
&lt;td>JSON-RPC method&lt;/td>
&lt;td>&lt;code>&amp;quot;tools/call&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.tool.name&lt;/code>&lt;/td>
&lt;td>Name of the tool&lt;/td>
&lt;td>&lt;code>&amp;quot;read_file&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.resource.uri&lt;/code>&lt;/td>
&lt;td>URI of the resource&lt;/td>
&lt;td>&lt;code>&amp;quot;file:///docs/api.md&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.prompt.name&lt;/code>&lt;/td>
&lt;td>Name of the prompt&lt;/td>
&lt;td>&lt;code>&amp;quot;code_review&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.session.id&lt;/code>&lt;/td>
&lt;td>MCP session ID&lt;/td>
&lt;td>&lt;code>&amp;quot;sess-abc123&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.protocol.version&lt;/code>&lt;/td>
&lt;td>Protocol version&lt;/td>
&lt;td>&lt;code>&amp;quot;2026-03-01&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.client.name&lt;/code>&lt;/td>
&lt;td>Client identity&lt;/td>
&lt;td>&lt;code>&amp;quot;ClaudeDesktop/1.2.0&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.server.name&lt;/code>&lt;/td>
&lt;td>Server identity&lt;/td>
&lt;td>&lt;code>&amp;quot;filesystem-mcp/0.5.2&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.transport&lt;/code>&lt;/td>
&lt;td>Transport used&lt;/td>
&lt;td>&lt;code>&amp;quot;stdio&amp;quot;&lt;/code> or &lt;code>&amp;quot;http&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.error.code&lt;/code>&lt;/td>
&lt;td>JSON-RPC error code&lt;/td>
&lt;td>&lt;code>-32602&lt;/code> (Invalid params)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.usage.input_tokens&lt;/code>&lt;/td>
&lt;td>Tokens consumed (if sampling)&lt;/td>
&lt;td>&lt;code>1240&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.usage.output_tokens&lt;/code>&lt;/td>
&lt;td>Tokens generated (if sampling)&lt;/td>
&lt;td>&lt;code>512&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last two come from the generic GenAI semantic conventions and apply when the MCP call involves sampling (the server using the client&amp;rsquo;s LLM).&lt;/p>
&lt;h3 id="red-metrics-per-tool">RED metrics per tool&lt;/h3>
&lt;p>Beyond spans, the semantic conventions define three core metrics:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>mcp.tool.call.duration&lt;/code>&lt;/strong> (histogram): the latency of each invocation.&lt;/li>
&lt;li>&lt;strong>&lt;code>mcp.tool.call.count&lt;/code>&lt;/strong> (counter): the total number of invocations.&lt;/li>
&lt;li>&lt;strong>&lt;code>mcp.tool.call.errors&lt;/code>&lt;/strong> (counter): errors per tool.&lt;/li>
&lt;/ul>
&lt;p>Labelled with &lt;code>mcp.tool.name&lt;/code>, &lt;code>mcp.server.name&lt;/code>, &lt;code>mcp.client.name&lt;/code>. Pivotable in Grafana to answer &amp;ldquo;which tool is the slowest&amp;rdquo;, &amp;ldquo;which tool fails most&amp;rdquo;, &amp;ldquo;which client loads which server hardest&amp;rdquo;.&lt;/p>
&lt;h2 id="trace-context-propagation-the-params_meta-trick">Trace context propagation: the &lt;code>params._meta&lt;/code> trick&lt;/h2>
&lt;p>JSON-RPC has no headers like HTTP, so MCP cannot use the W3C &lt;code>traceparent&lt;/code> header directly. The solution the ecosystem has converged on: &lt;strong>propagate trace context in &lt;code>params._meta&lt;/code>&lt;/strong>.&lt;/p>
&lt;p>When the MCP client sends a &lt;code>tools/call&lt;/code>, its OTel instrumentation does:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">json&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">opentelemetry.propagate&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">inject&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">carrier&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">inject&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">carrier&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># fills it with traceparent/tracestate from the active span&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">params&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;read_file&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;arguments&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;path&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;/notes.md&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;_meta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">carrier&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># ← propagates trace context&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>When the server receives it, it does the symmetrical thing:&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">opentelemetry.propagate&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">extract&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">ctx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">extract&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">params&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;_meta&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{}))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">tracer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">start_as_current_span&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;tools/call&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># this span is a child of the client&amp;#39;s&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">execute_tool&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">params&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The result: the server&amp;rsquo;s span is a child of the client&amp;rsquo;s span in the trace tree. When you look at the trace in Tempo or Phoenix, you see the whole chain: user → host → client → server → execution → response → client → host → response to the user.&lt;/p>
&lt;p>This requires both ends to instrument consistently. If the server does not extract the context, you see disconnected spans but at least you have traceability on the client side.&lt;/p>
&lt;h2 id="instrumentation-patterns">Instrumentation patterns&lt;/h2>
&lt;p>There are three routes to instrumenting MCP, in increasing order of effort:&lt;/p>
&lt;h3 id="1-fastmcp-with-built-in-opentelemetry">1. FastMCP with built-in OpenTelemetry&lt;/h3>
&lt;p>&lt;a href="https://gofastmcp.com/">FastMCP&lt;/a> is one of the most used Python frameworks for building MCP servers. It ships built-in OpenTelemetry instrumentation: every tool, resource template and prompt operation generates spans automatically with the correct MCP conventions.&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">fastmcp&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">FastMCP&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">opentelemetry.sdk.trace.export&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">OTLPSpanExporter&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">mcp&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">FastMCP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;my-server&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">otel_endpoint&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;https://otel-collector:4318&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@mcp.tool&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">search_docs&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Search the corpus for matching documents.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># this automatically generates a span with&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># mcp.tool.name=search_docs, mcp.method.name=tools/call, etc.&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">run_search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Zero instrumentation code. Spans with the correct conventions. It is the recommended pattern if you are starting an MCP server in Python from scratch.&lt;/p>
&lt;h3 id="2-manual-opentelemetry-sdk">2. Manual OpenTelemetry SDK&lt;/h3>
&lt;p>For servers that already exist or are in other languages (TypeScript, Go), the option is to instrument manually with the standard OTel SDK and emit the conventional MCP attributes:&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">opentelemetry&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">trace&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tracer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">trace&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_tracer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="vm">__name__&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">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">handle_tools_call&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">req&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">JSONRPCRequest&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ctx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">extract_trace_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">req&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">tracer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">start_as_current_span&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;mcp.tools.call&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">span&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_attribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;mcp.method.name&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tools/call&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_attribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;mcp.tool.name&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">req&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">params&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_attribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;mcp.server.name&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;filesystem-mcp&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">try&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">execute_tool&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">req&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">params&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">result&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">except&lt;/span> &lt;span class="ne">Exception&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">e&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_attribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;mcp.error.code&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">-&lt;/span>&lt;span class="mi">32603&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">record_exception&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">e&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>More boilerplate but it works with any existing server.&lt;/p>
&lt;h3 id="3-mcp-inspector-for-interactive-debugging">3. MCP Inspector for interactive debugging&lt;/h3>
&lt;p>&lt;a href="https://github.com/modelcontextprotocol/inspector">MCP Inspector&lt;/a> (official) is a tool for interactive protocol-level debugging. It launches a local proxy (port 6277) between your client and the server, and opens a web UI (port 6274) where you see every JSON-RPC message back and forth in real time.&lt;/p>
&lt;p>It is not production observability, it is development and debugging. But it is irreplaceable during the bring-up of a new server: you see exactly which requests arrive, which responses are returned, which errors occur. It saves hours of ad-hoc logging.&lt;/p>
&lt;h2 id="mcp-gateways-the-centralised-piece-for-enterprise">MCP Gateways: the centralised piece for enterprise&lt;/h2>
&lt;p>When your organisation has many agents connecting to many MCP servers, managing the connection matrix becomes operationally serious. The natural question, &amp;ldquo;can there be a proxy in front of all the MCP servers that centralises auth, rate limiting, logging and observability?&amp;rdquo;, already has an answer: &lt;strong>MCP Gateways&lt;/strong>.&lt;/p>
&lt;p>An MCP Gateway is a proxy that:&lt;/p>
&lt;ul>
&lt;li>Accepts MCP connections from the hosts/agents.&lt;/li>
&lt;li>Routes them to the corresponding backend MCP servers.&lt;/li>
&lt;li>Applies centralised &lt;strong>authentication and authorisation&lt;/strong> (which agent can call which tool).&lt;/li>
&lt;li>Applies &lt;strong>rate limiting&lt;/strong> per agent, per tool, per tenant.&lt;/li>
&lt;li>&lt;strong>Observes&lt;/strong>: it emits OTel metrics for every operation passing through.&lt;/li>
&lt;li>&lt;strong>Propagates the identity&lt;/strong> of the agent to the backend server (with several models: token forwarding, token exchange, impersonation).&lt;/li>
&lt;/ul>
&lt;p>The options that have established themselves in 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://doc.traefik.io/traefik-hub/mcp-gateway/">Traefik Hub MCP Gateway&lt;/a>&lt;/strong> — from the Traefik team. Declarative configuration, native integration with Traefik&amp;rsquo;s Kubernetes/Helm ecosystem.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.mintmcp.com/">MintMCP&lt;/a>&lt;/strong> — a gateway focused on observability and multi-tenancy. SaaS and self-host.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://openobserve.ai/blog/mcp-gateway-guide/">OpenObserve MCP Gateway&lt;/a>&lt;/strong> — integrated with the OpenObserve observability platform.&lt;/li>
&lt;/ul>
&lt;p>For small deployments (one team, few agents) a Gateway can be overkill. For enterprise (dozens of agents, dozens of servers, regulated compliance), it is practically mandatory.&lt;/p>
&lt;h2 id="real-use-cases-for-mcp-observability">Real use cases for MCP observability&lt;/h2>
&lt;p>Let us land this with five cases where properly instrumented MCP observability delivers immediate value:&lt;/p>
&lt;h3 id="1-audit-per-tool-per-tenant-per-agent">1. Audit per tool, per tenant, per agent&lt;/h3>
&lt;p>Question: &amp;ldquo;who ran the &lt;code>delete_repo&lt;/code> tool last month?&amp;rdquo;. Without MCP observability, impossible. With OTel conventions + identity propagation: a query in your trace backend filtering by &lt;code>mcp.tool.name=&amp;quot;delete_repo&amp;quot;&lt;/code>, grouping by &lt;code>mcp.client.name&lt;/code> or by the user_id propagated in &lt;code>_meta&lt;/code>. Compliance happy.&lt;/p>
&lt;h3 id="2-cost-per-tool-and-per-tenant">2. Cost per tool and per tenant&lt;/h3>
&lt;p>Question: &amp;ldquo;how much does each tool cost?&amp;rdquo;. If the tools invoke external APIs (Stripe, OpenAI sampling) or consume significant resources (a GPU for an inference tool), knowing their aggregated cost matters. With &lt;code>mcp.tool.call.duration&lt;/code> + &lt;code>gen_ai.usage.*&lt;/code> aggregated by tool and tenant, you build cost accountability dashboards without instrumenting anything extra.&lt;/p>
&lt;h3 id="3-debugging-multistep-chains-that-fail">3. Debugging multistep chains that fail&lt;/h3>
&lt;p>Question: &amp;ldquo;the agent failed to complete this task, where did it go wrong?&amp;rdquo;. The propagated trace connects: the user&amp;rsquo;s span → the LLM&amp;rsquo;s span with its CoT → the spans of each tool invoked → the final LLM span. If the chain broke at the third tool, in Tempo you see the red span with the specific error message. Reproducing the failure is trivial.&lt;/p>
&lt;h3 id="4-tool-latency-and-degradation">4. Tool latency and degradation&lt;/h3>
&lt;p>Question: &amp;ldquo;which tool is degrading?&amp;rdquo;. RED metrics per tool in Grafana show p95/p99 latency over time. When a tool starts climbing from 200ms to 800ms (because the underlying service is collapsing), you see it before the users complain.&lt;/p>
&lt;h3 id="5-detecting-loops-and-agentic-anomalies">5. Detecting loops and agentic anomalies&lt;/h3>
&lt;p>Question: &amp;ldquo;is any agent stuck in a loop?&amp;rdquo;. If an agent calls &lt;code>tools/call read_file&lt;/code> 80 times in 30 seconds for the same path, something is clearly wrong. An alert on &lt;code>mcp.tool.call.count&lt;/code> grouped by (session_id, tool_name) detects this. Combined with loop detection at the reasoning level, it closes the circle.&lt;/p>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;h3 id="lack-of-identity-propagation">Lack of identity propagation&lt;/h3>
&lt;p>Your Gateway authenticates the agent, but passes requests to the backend without propagating identity. The result: the backend&amp;rsquo;s logs say &amp;ldquo;service-account&amp;rdquo; for everything, and it is impossible to audit who invoked what. &lt;strong>Choose a propagation strategy early&lt;/strong>: token forwarding (simple, exposes tokens to the backend), token exchange (more secure), or impersonation with cross-logging.&lt;/p>
&lt;h3 id="stdio-servers-that-do-not-show-up-in-your-apm">Stdio servers that do not show up in your APM&lt;/h3>
&lt;p>This is the field&amp;rsquo;s number 1 pitfall. Your Cursor agent uses filesystem-mcp over stdio; you see nothing in Datadog because there is no network traffic. Solution: instrument the stdio server with an OTel SDK that exports over OTLP to your collector (via gRPC or HTTP; the OTel collector can receive even though the server talks stdio to its client). Or use AgentSight &lt;code>stdiocap&lt;/code> to capture the raw JSON-RPC and process it offline.&lt;/p>
&lt;h3 id="multiple-protocol-versions-in-production">Multiple protocol versions in production&lt;/h3>
&lt;p>Different clients use different MCP versions simultaneously. Your metrics dashboard mixes apples and oranges. ALWAYS label with &lt;code>mcp.protocol.version&lt;/code> and filter/group by it.&lt;/p>
&lt;h3 id="_meta-lost-when-passing-through-a-proxy">&lt;code>_meta&lt;/code> lost when passing through a proxy&lt;/h3>
&lt;p>Your Gateway accepts the client&amp;rsquo;s request, rewrites it for the backend, and forgets to copy &lt;code>params._meta&lt;/code>. The result: the trace is broken at the Gateway, two disconnected traces. Make sure your Gateway &lt;strong>preserves or re-injects&lt;/strong> trace context at each hop.&lt;/p>
&lt;h3 id="trace-volume-with-chatty-servers">Trace volume with chatty servers&lt;/h3>
&lt;p>Some MCP servers emit many small operations (filesystem listings, partial reads). Without sampling, they fill your backend with useless traces. Apply &lt;strong>tail-based sampling&lt;/strong> that keeps complete sessions or keeps only traces with errors or high latency.&lt;/p>
&lt;h3 id="cardinality-in-metrics">Cardinality in metrics&lt;/h3>
&lt;p>&lt;code>mcp.tool.call.duration&lt;/code> with &lt;code>mcp.session.id&lt;/code> as a label blows up cardinality. &lt;strong>Do not include unique per-session IDs in labels&lt;/strong>; keep cardinality under control with labels that take few discrete values (tool name, server name, client name, error code).&lt;/p>
&lt;h3 id="confusing-client-and-server-spans">Confusing client and server spans&lt;/h3>
&lt;p>When you look at the tree, distinguish: the client sees the total latency from its perspective (network included); the server sees only its own work. If you look only at the server span to debug the latency perceived by the user, you miss the RTT. Use both.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Experimental WebSocket MCP transport&lt;/strong>: an alternative to Streamable HTTP, not yet standard.&lt;/li>
&lt;li>&lt;strong>MCP servers in cloud-native deployments with sidecars&lt;/strong>: an emerging pattern of deploying MCP servers as pod sidecars.&lt;/li>
&lt;li>&lt;strong>MCP federation&lt;/strong>: composing several servers as one (similar to GraphQL federation).&lt;/li>
&lt;li>&lt;strong>eBPF + MCP&lt;/strong>: how AgentSight&amp;rsquo;s &lt;code>stdiocap&lt;/code> and Cilium&amp;rsquo;s hooks complement native instrumentation.&lt;/li>
&lt;li>&lt;strong>MCP testing and contract tests&lt;/strong>: how to validate that your server complies with the spec.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Specification and concepts:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://modelcontextprotocol.io/">Model Context Protocol — sitio oficial&lt;/a> — entrada canónica.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/docs/learn/architecture">MCP architecture overview&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.info/docs/concepts/transports/">Transports — MCP docs&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/modelcontextprotocol/inspector">MCP Inspector (GitHub)&lt;/a> — debugging interactivo.&lt;/li>
&lt;/ul>
&lt;p>OpenTelemetry GenAI MCP:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/">Semantic conventions for Model Context Protocol — OpenTelemetry&lt;/a> — referencia normativa.&lt;/li>
&lt;li>&lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/269">Adding OpenTelemetry Trace Support to MCP (Discussion #269)&lt;/a> — historia de la propuesta.&lt;/li>
&lt;li>&lt;a href="https://oneuptime.com/blog/post/2026-03-26-how-to-instrument-mcp-servers-with-opentelemetry/view">How to Instrument MCP Servers with OpenTelemetry (OneUptime)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.elastic.co/observability-labs/blog/mcp-tracing-opentelemetry-elastic-apm">How to trace MCP server tool calls with OpenTelemetry and Elastic APM&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://signoz.io/blog/mcp-observability-with-otel/">MCP Observability with OpenTelemetry (SigNoz)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://developers.redhat.com/articles/2026/04/06/distributed-tracing-agentic-workflows-opentelemetry">Distributed tracing for agentic workflows (Red Hat Developer)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.mintmcp.com/blog/opentelemetry-ai-agents">OpenTelemetry for AI Agents in MCP Workflows (MintMCP)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Frameworks and gateways:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://gofastmcp.com/servers/telemetry">FastMCP OpenTelemetry&lt;/a> — instrumentación built-in.&lt;/li>
&lt;li>&lt;a href="https://doc.traefik.io/traefik-hub/mcp-gateway/">Traefik Hub MCP Gateway&lt;/a> — gateway de Traefik.&lt;/li>
&lt;li>&lt;a href="https://www.mintmcp.com/">MintMCP&lt;/a> — gateway con foco en observabilidad.&lt;/li>
&lt;li>&lt;a href="https://openobserve.ai/blog/mcp-gateway-guide/">OpenObserve MCP Gateway guide&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://dev.to/composiodev/what-is-an-mcp-gateway-and-why-do-enterprise-ai-teams-need-one-in-2026-1lie">What is an MCP Gateway (DEV Community)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/traceloop/opentelemetry-mcp-server">OpenTelemetry MCP Server (Traceloop)&lt;/a> — el patrón inverso: usar MCP para que agentes consulten traces OTel.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Post anterior: &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails y safety&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight y el nuevo tracing de LLMs&lt;/a> — donde se introdujo &lt;code>stdiocap&lt;/code> para capturar stdio de servidores MCP locales.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: la capa después del tracing&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>AgentSight and the new LLM tracing: zero-instrumentation with eBPF against Langfuse, LangSmith, Phoenix and company</title><link>https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/</link><pubDate>Tue, 19 May 2026 18:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Observing an LLM agent in production in 2026 splits into two approaches with opposite philosophies. The &lt;strong>instrumented&lt;/strong> one, dominant until 2025, lives in tools such as &lt;a href="https://langfuse.com/">Langfuse&lt;/a>, &lt;a href="https://www.langchain.com/langsmith">LangSmith&lt;/a>, &lt;a href="https://phoenix.arize.com/">Arize Phoenix&lt;/a>, &lt;a href="https://www.helicone.ai/">Helicone&lt;/a>, &lt;a href="https://www.traceloop.com/openllmetry">OpenLLMetry/Traceloop&lt;/a> or &lt;a href="https://pydantic.dev/logfire">Pydantic Logfire&lt;/a>: you install an SDK, decorate your calls, emit spans with the &lt;strong>OpenTelemetry GenAI&lt;/strong> convention (&lt;code>gen_ai.request.model&lt;/code>, &lt;code>gen_ai.usage.input_tokens&lt;/code>, and so on) and export them to a backend. Enormous depth when you control the code; zero visibility when the agent is an opaque binary you run without instrumenting. The &lt;strong>zero-instrumentation&lt;/strong> one, which &lt;a href="https://github.com/eunomia-bpf/agentsight">AgentSight&lt;/a> popularised in the second half of 2025, turns the perspective 180º: it puts &lt;strong>eBPF hooks on the uprobes of the SSL/TLS libraries&lt;/strong> and captures the plaintext of every HTTPS request before encryption, &lt;strong>without touching the app&amp;rsquo;s code&lt;/strong>, with less than &lt;strong>3% overhead&lt;/strong> and the guarantee of being &lt;strong>tamper-proof&lt;/strong> (the agent cannot falsify what is seen in the kernel). Combined with BPF capture of stdio for local MCP servers, AgentSight gives you complete observability of any agent, including closed binaries such as Claude Code, Gemini CLI or Cursor, on a Kubernetes cluster. The two families are not enemies: the 2026 reference stack combines both (instrumented for your own apps with LangChain, eBPF for opaque binaries and tamper-proof compliance) over &lt;strong>OpenTelemetry GenAI semantic conventions&lt;/strong> as the common vocabulary the ecosystem is stabilising this year.&lt;/p>
&lt;blockquote>
&lt;p>This is the &lt;strong>fourth and last post of the eBPF series&lt;/strong>. Part 1: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>. Part 2: &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon: runtime security&lt;/a>. Part 3: &lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble: network observability&lt;/a>. Here we close the circle with the &lt;strong>semantic&lt;/strong> dimension: what an AI agent does, not only what network it opens or what syscalls it emits.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-traditional-apm-vs-network-sniffer">The analogy: traditional APM vs network sniffer&lt;/h2>
&lt;p>Anyone who has operated enterprise applications knows the two tribes of monitoring. The &lt;strong>APM&lt;/strong> tribe (New Relic, AppDynamics, Datadog APM): you install an agent or an SDK in each application, mark spans, collect traces with enormous depth inside each process, down to lines of code, SQL queries, Java methods. The &lt;strong>wire-level&lt;/strong> tribe (network sniffers, SolarWinds NPM-style tools, NetFlow): it does not touch the application; it observes the cable, sees protocols, latencies, retransmissions, identifies problems the app does not know it has.&lt;/p>
&lt;p>Each one sees different things and both are useful. Anyone who has lived through a serious incident where APM said &amp;ldquo;all green&amp;rdquo; while users suffered knows that wire-level would have caught the problem (a saturated middlebox, a badly configured MTU, a TCP timeout). Anyone who has tried to debug a memory leak with sniffers knows it was impossible without APM.&lt;/p>
&lt;p>LLM agent observability in 2026 is exactly at this point. The &lt;strong>APM-style&lt;/strong> has been in place for a couple of years: Langfuse, LangSmith, Phoenix, OpenLLMetry. Enormous depth, requires instrumenting the app. The &lt;strong>wire-level with eBPF&lt;/strong> has just arrived: AgentSight is the first project to take it to production. Less depth inside the agent, but it sees any agent without touching anything and it is &lt;strong>tamper-proof&lt;/strong>. Both are useful. The industry is in full coexistence.&lt;/p>
&lt;h2 id="why-observing-llm-agents-is-different">Why observing LLM agents is different&lt;/h2>
&lt;p>Before getting into tools, it is worth pausing on what makes LLM agents specific as subjects of observability:&lt;/p>
&lt;p>&lt;strong>Non-determinism.&lt;/strong> The same input can produce different outputs. Reproducing an incident requires capturing &lt;strong>exactly&lt;/strong> the conversation, the model, the parameters and, ideally, the seed. An aggregate &amp;ldquo;p95 latency&amp;rdquo; metric falls short; what you need is replay of the individual trace.&lt;/p>
&lt;p>&lt;strong>A chain of external invocations.&lt;/strong> A typical agent calls LLM → tools (tool calling) → MCP servers → other APIs → back to the LLM. A chat session can generate &lt;strong>dozens of chained calls&lt;/strong> that have to be correlated by trace_id to understand the decision.&lt;/p>
&lt;p>&lt;strong>Cost linear in tokens.&lt;/strong> Every call is paid for in tokens. Without tracing input/output tokens per request, you cannot assign cost to a tenant or a team, nor detect loops that eat your budget in an hour.&lt;/p>
&lt;p>&lt;strong>Semantic risk.&lt;/strong> Prompt injection (a user input containing instructions to manipulate the model), jailbreaks, secret leakage through tool calls. It is a class of problem that does not appear in traditional applications, and observability must see it.&lt;/p>
&lt;p>&lt;strong>Opaque binaries.&lt;/strong> In 2026, many teams deploy &lt;strong>third-party agents&lt;/strong> such as Claude Code, the Cursor agent, Aider, Gemini CLI or Codex CLI as internal tools. They are not your own applications; they are closed binaries that call the vendor&amp;rsquo;s API. Instrumenting them is impossible. Observing them requires something else.&lt;/p>
&lt;p>&lt;strong>Multi-agent and orchestration.&lt;/strong> More and more architectures have agents that invoke other agents (planner → executor → critic). Observability must understand the topology, not just the individual span.&lt;/p>
&lt;p>With these five points in mind, the tools we are about to see differ mainly in &lt;strong>which parts&lt;/strong> of the problem they cover well and &lt;strong>which parts&lt;/strong> they leave blind.&lt;/p>
&lt;h2 id="the-instrumented-approach-how-it-works">The instrumented approach: how it works&lt;/h2>
&lt;p>The model is direct and familiar:&lt;/p>
&lt;ol>
&lt;li>Your code calls the LLM or the tools using an official library: &lt;code>openai&lt;/code>, &lt;code>anthropic&lt;/code>, &lt;code>langchain&lt;/code>, &lt;code>llama_index&lt;/code>, &lt;code>dspy&lt;/code>.&lt;/li>
&lt;li>You install a tracer SDK (Langfuse, LangSmith, OpenLLMetry, Logfire) that &lt;strong>wraps&lt;/strong> or &lt;strong>monkey-patches&lt;/strong> those libraries.&lt;/li>
&lt;li>Each call emits an &lt;strong>OpenTelemetry span&lt;/strong> with standardised attributes: model used, input/output tokens, latency, parameters, messages, tool invoked, result.&lt;/li>
&lt;li>The spans are exported over OTLP to a backend that shows them as a tree of traces.&lt;/li>
&lt;/ol>
&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"># Typical example with OpenLLMetry + any SDK&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">traceloop.sdk&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Traceloop&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">openai&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">OpenAI&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">Traceloop&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">init&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;my-agent&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">api_endpoint&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;https://otel-collector:4318&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">OpenAI&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># this call automatically emits a span with&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># gen_ai.request.model, gen_ai.usage.input_tokens, etc.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">resp&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chat&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">completions&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;gpt-4.1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">messages&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&amp;#34;&lt;/span>&lt;span class="p">}]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What you see afterwards: a dashboard with each conversation as a trace, each call as a span, the complete prompts and completions (if you opt in), the computed cost, latencies per span, errors flagged.&lt;/p>
&lt;h3 id="opentelemetry-genai-semantic-conventions-the-common-vocabulary">OpenTelemetry GenAI semantic conventions: the common vocabulary&lt;/h3>
&lt;p>The fragmentation of the field is being mitigated with &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">&lt;strong>OpenTelemetry GenAI Semantic Conventions&lt;/strong>&lt;/a>. It is the CNCF&amp;rsquo;s effort to get &lt;strong>all&lt;/strong> tools to emit spans with the same attribute names:&lt;/p>
&lt;ul>
&lt;li>&lt;code>gen_ai.system&lt;/code> — the provider (openai, anthropic, vertex_ai, and so on).&lt;/li>
&lt;li>&lt;code>gen_ai.request.model&lt;/code> — the model requested (&lt;code>gpt-4.1&lt;/code>, &lt;code>claude-3-5-sonnet&lt;/code>).&lt;/li>
&lt;li>&lt;code>gen_ai.response.model&lt;/code> — the model actually used (sometimes it differs, e.g. fallbacks).&lt;/li>
&lt;li>&lt;code>gen_ai.usage.input_tokens&lt;/code> and &lt;code>gen_ai.usage.output_tokens&lt;/code> — counters.&lt;/li>
&lt;li>&lt;code>gen_ai.request.temperature&lt;/code>, &lt;code>gen_ai.request.top_p&lt;/code>, and so on — parameters.&lt;/li>
&lt;li>&lt;code>gen_ai.response.finish_reasons&lt;/code> — why it ended (stop, length, content_filter).&lt;/li>
&lt;li>&lt;code>gen_ai.operation.name&lt;/code> — the type of operation (chat, embedding, completion).&lt;/li>
&lt;/ul>
&lt;p>In early 2026, the &lt;strong>client spans&lt;/strong> moved from experimental to stable. The rest (server spans, multi-agent events) is still in development. The operational meaning: if your SDK emits these attributes, &lt;strong>any backend that understands OTel GenAI&lt;/strong> can consume them. Moving from Langfuse to Phoenix to Helicone does not imply re-instrumenting, only changing the exporter.&lt;/p>
&lt;p>The SIG is actively developing &lt;strong>conventions for multi-agent systems&lt;/strong>: agent teams, tasks, actions, memory, artifact tracking. This is what is missing for complex agent architectures to have a common vocabulary. In 2026 it is experimental; stabilisation is expected at the end of the year or the start of 2027.&lt;/p>
&lt;h3 id="instrumented-tools-the-2026-landscape">Instrumented tools: the 2026 landscape&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Self-host&lt;/th>
&lt;th>Focus&lt;/th>
&lt;th>Where it shines&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Langfuse&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>LLM observability + evals + prompt mgmt&lt;/td>
&lt;td>Best OSS balance, complete suite&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LangSmith&lt;/strong>&lt;/td>
&lt;td>Commercial&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Native LangChain/LangGraph&lt;/td>
&lt;td>If you use LangChain, zero-config integration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Arize Phoenix&lt;/strong>&lt;/td>
&lt;td>ELv2 (OSS)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>OTel-native, strong RAG&lt;/td>
&lt;td>Vector DBs, retrieval, embeddings&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Helicone&lt;/strong>&lt;/td>
&lt;td>Commercial + OSS lite&lt;/td>
&lt;td>Yes (lite)&lt;/td>
&lt;td>Simple proxy&lt;/td>
&lt;td>Setup in minutes, OpenAI-only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OpenLLMetry / Traceloop&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>OTel SDK for LLMs&lt;/td>
&lt;td>Vendor-neutral, exports to any OTel backend&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Pydantic Logfire&lt;/strong>&lt;/td>
&lt;td>Commercial&lt;/td>
&lt;td>No&lt;/td>
&lt;td>App + LLM unified&lt;/td>
&lt;td>If you use Pydantic AI, native integration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Weights &amp;amp; Biases Weave&lt;/strong>&lt;/td>
&lt;td>Commercial&lt;/td>
&lt;td>Limited&lt;/td>
&lt;td>Experimentation + production&lt;/td>
&lt;td>If you already use W&amp;amp;B for training&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Laminar / Braintrust&lt;/strong>&lt;/td>
&lt;td>Commercial&lt;/td>
&lt;td>No / Yes&lt;/td>
&lt;td>Evals + tracing&lt;/td>
&lt;td>More recent, focused on evaluation&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="deep-dive-langfuse">Deep dive: Langfuse&lt;/h3>
&lt;p>It is worth pausing on &lt;a href="https://langfuse.com/">Langfuse&lt;/a> because in 2026 it is &lt;strong>the default choice among the open-source options&lt;/strong> and the one most teams have adopted this year. It is a &lt;a href="https://github.com/langfuse/langfuse">YC W23&lt;/a> project, &lt;strong>MIT&lt;/strong> licence, and keeps a sustained release cadence with serious architectural changes between versions.&lt;/p>
&lt;p>&lt;strong>Four declared pillars&lt;/strong>: observability (tracing), evaluations, prompt management, playground/datasets. Each one separately has complete commercial products behind it; Langfuse integrates them into a single platform with a single backend.&lt;/p>
&lt;h4 id="sdk-v4-otel-native-not-a-replacement">SDK v4: OTEL-native, not a replacement&lt;/h4>
&lt;p>The big recent operational change is the &lt;strong>SDK v4&lt;/strong>, a thin layer over the official OpenTelemetry client. The choice is deliberate: instead of maintaining a client of its own that would fall behind the OTel primitives, Langfuse uses the standard SDK and &lt;strong>enriches&lt;/strong> the spans with LLM-specific attributes and helpers. The consequence: any code already instrumented with vanilla OpenTelemetry (&lt;code>@opentelemetry/sdk-node&lt;/code>, &lt;code>opentelemetry-sdk&lt;/code> in Python) &lt;strong>can export to Langfuse without major changes&lt;/strong>, and the other way round, if tomorrow you want to migrate from Langfuse to another OTel backend, the spans are portable.&lt;/p>
&lt;p>In Python the idiomatic decorator is &lt;code>@observe&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">observe&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">get_client&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">langfuse&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">get_client&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@observe&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">buscar_documentos&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># any internal call is traced as well&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">vector_store&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">similarity_search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@observe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">as_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;generation&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">llamar_llm&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># marked as &amp;#34;generation&amp;#34; so it appears with LLM metadata&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">openai_client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chat&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">completions&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create&lt;/span>&lt;span class="p">(&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@observe&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">pipeline_rag&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pregunta&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">docs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">buscar_documentos&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pregunta&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">llamar_llm&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">build_prompt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pregunta&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">docs&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The call tree is captured automatically: the trace shows &lt;code>pipeline_rag&lt;/code> as the root span, with &lt;code>buscar_documentos&lt;/code> and &lt;code>llamar_llm&lt;/code> as children, nested. Without writing a single &lt;code>with tracer.start_as_current_span(...)&lt;/code> by hand.&lt;/p>
&lt;p>In TypeScript the equivalent is modular: you install &lt;code>@langfuse/tracing&lt;/code>, &lt;code>@langfuse/otel&lt;/code> and &lt;code>@opentelemetry/sdk-node&lt;/code>, and you can use TS decorators, context managers or manual spans, the three models interoperate. The consequence: third-party libraries that emit OTel spans (&lt;code>openai&lt;/code>, &lt;code>@anthropic-ai/sdk&lt;/code>, Vercel AI SDK instrumentations) show up in Langfuse with no extra work.&lt;/p>
&lt;h4 id="self-host-architecture-designed-for-serious-production">Self-host architecture: designed for serious production&lt;/h4>
&lt;p>The Langfuse backend architecture has &lt;strong>two explicit decisions&lt;/strong> that set its self-host deployment apart:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Persistence in S3/Blob Storage first&lt;/strong>. When a tracing event arrives, &lt;strong>it is persisted in object storage before touching the database&lt;/strong>. Only when the later processing confirms OK is it inserted into Postgres/Clickhouse. If the DB goes down temporarily, the events &lt;strong>are not lost&lt;/strong>; they stay in S3 waiting to be reprocessed. For production where losing the traces of an incident is equivalent to losing evidence, this is load-bearing.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Long migrations as background jobs&lt;/strong>. Schema upgrades that on other platforms mean a downtime window run in the background on Langfuse while the application keeps serving. Upgrade downtime drops drastically.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>The officially supported deployment modes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Docker Compose&lt;/strong>: for development and POCs. One command, everything up.&lt;/li>
&lt;li>&lt;strong>VM&lt;/strong>: a single node, containers, no orchestration. For small environments.&lt;/li>
&lt;li>&lt;strong>Kubernetes with Helm&lt;/strong>: the recommended mode for production. Official chart maintained. Supports external Postgres, external Clickhouse, external S3, HPA.&lt;/li>
&lt;/ul>
&lt;p>The external dependencies in a typical production setup: &lt;strong>Postgres&lt;/strong> (metadata, prompts, configuration), &lt;strong>Clickhouse&lt;/strong> (tracing events, high-cardinality queries), &lt;strong>S3 or a compatible blob store&lt;/strong> (pending events), &lt;strong>Redis&lt;/strong> (the queue between components). Yes, that is several pieces; it is what holds up the durability and the scale.&lt;/p>
&lt;h4 id="prompt-management-as-a-first-class-citizen">Prompt management as a first-class citizen&lt;/h4>
&lt;p>What sets Langfuse apart from platforms focused only on tracing is that &lt;strong>the prompts live in Langfuse&lt;/strong>, not in the application repo or in spreadsheets. Each prompt has:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Name and version&lt;/strong> (v1, v2, v3&amp;hellip;). Changing the prompt does not require redeploying the app: the app asks the SDK for the prompt, and the SDK caches it and refreshes when there is a new version.&lt;/li>
&lt;li>&lt;strong>Typed variables&lt;/strong>: &lt;code>{{user_input}}&lt;/code>, &lt;code>{{context}}&lt;/code>. Rendering with validation.&lt;/li>
&lt;li>&lt;strong>Tags and labels&lt;/strong>: by environment (&lt;code>production&lt;/code>, &lt;code>staging&lt;/code>), by team, by experiment.&lt;/li>
&lt;li>&lt;strong>Client and server cache&lt;/strong>: the SDK caches locally with a configurable TTL, avoiding a roundtrip to Langfuse on every call.&lt;/li>
&lt;li>&lt;strong>Linkage with traces&lt;/strong>: each trace records which exact version of which prompt was used. Investigating &amp;ldquo;this answer came out wrong&amp;rdquo; leads to prompt version Y, not to &amp;ldquo;some version of the prompt at some point&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">get_client&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">langfuse&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">get_client&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">prompt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_prompt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;rag-system-prompt&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># or by label: langfuse.get_prompt(&amp;#34;rag-system-prompt&amp;#34;, label=&amp;#34;production&amp;#34;)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">compiled&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">prompt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">compile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">docs_text&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">user_input&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">question&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># &amp;#39;compiled&amp;#39; is the final string, ready to send to the LLM&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For teams iterating on prompts daily, this is what avoids the chaos of &amp;ldquo;which version of the prompt is actually running in production right now&amp;rdquo;.&lt;/p>
&lt;h4 id="evaluations-four-combinable-evaluation-models">Evaluations: four combinable evaluation models&lt;/h4>
&lt;p>Langfuse covers the four patterns of answer evaluation:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>LLM-as-a-judge&lt;/strong>: you configure a model (typically GPT-4 or Claude) with a rubric and it evaluates each answer. Result: a numeric score (0-1) and a justification. Applicable to automatic tracing (all answers) or batch (a dataset selection).&lt;/li>
&lt;li>&lt;strong>User feedback&lt;/strong>: the app lets the user mark an answer as good/bad. The feedback is associated with the trace and the prompt version, which lets you see which versions have a worse rate.&lt;/li>
&lt;li>&lt;strong>Manual labeling&lt;/strong>: a UI where human labelers score answers. Useful for golden datasets and for evaluating the judge.&lt;/li>
&lt;li>&lt;strong>Custom evaluators through the API/SDK&lt;/strong>: your own evals (a unit test, a business metric) report a score through the API. They integrate with CI.&lt;/li>
&lt;/ul>
&lt;p>Combined, they give &lt;strong>regression testing&lt;/strong> of the prompt: you change from v3 to v4, evaluate the golden dataset with LLM-as-judge, compare; if v4 gets worse in any of the segments, the merge fails.&lt;/p>
&lt;h4 id="integrations">Integrations&lt;/h4>
&lt;p>Langfuse does not compete with OpenLLMetry, LangChain or LiteLLM: it &lt;strong>integrates&lt;/strong> them. The ones that are tested and documented:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OpenTelemetry&lt;/strong>: any OTel instrumentation emits to Langfuse over OTLP.&lt;/li>
&lt;li>&lt;strong>LangChain and LangGraph&lt;/strong>: a native callback that captures the whole chain.&lt;/li>
&lt;li>&lt;strong>LlamaIndex&lt;/strong>: native callback.&lt;/li>
&lt;li>&lt;strong>OpenAI SDK&lt;/strong> (Python and TS): a wrapper that adds tracing automatically.&lt;/li>
&lt;li>&lt;strong>LiteLLM&lt;/strong>: integration as a callback, which covers 100+ providers through LiteLLM.&lt;/li>
&lt;li>&lt;strong>OpenLLMetry / Traceloop&lt;/strong>: they emit to Langfuse like any OTel backend.&lt;/li>
&lt;li>&lt;strong>MLflow&lt;/strong>: through an OTel exporter from MLflow to Langfuse.&lt;/li>
&lt;li>&lt;strong>Vercel AI SDK&lt;/strong>: native instrumentation.&lt;/li>
&lt;/ul>
&lt;p>The strategy is clear: &lt;strong>Langfuse is a backend, not an SDK&lt;/strong>. Your team chooses how it instruments; Langfuse accepts any path. The operational consequence: moving from Langfuse to another OTel backend tomorrow is viable.&lt;/p>
&lt;h4 id="when-langfuse-is-not-the-answer">When Langfuse is not the answer&lt;/h4>
&lt;p>So as not to present it as a silver bullet:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>If you only use LangChain and have no resources to self-host&lt;/strong>: LangSmith will give you a smoother integration (it is the same team).&lt;/li>
&lt;li>&lt;strong>If your only need is a proxy with cost tracking and no evals&lt;/strong>: Helicone is simpler.&lt;/li>
&lt;li>&lt;strong>If you want an integrated commercial vendor solution&lt;/strong>: Datadog LLM Observability, New Relic AI Monitoring or Dynatrace AI are Enterprise alternatives with 24/7 support.&lt;/li>
&lt;li>&lt;strong>If your load is pure batch massive inference with no agents&lt;/strong>: you probably do not need semantic tracing; Prometheus + Grafana with OTel metrics is enough.&lt;/li>
&lt;/ul>
&lt;p>For everything else, your own apps with serious tracing, multi-tenant with quotas, teams iterating on prompts daily, RAG with continuous evaluation, Langfuse is the safe bet.&lt;/p>
&lt;p>&lt;strong>Quick choice summary&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>LangChain → LangSmith&lt;/strong> (zero effort, automatic instrumentation).&lt;/li>
&lt;li>&lt;strong>Your own multi-framework applications with OSS → Langfuse&lt;/strong> (MIT, self-host, complete).&lt;/li>
&lt;li>&lt;strong>RAG with vector stores → Arize Phoenix&lt;/strong> (better retrieval visibility).&lt;/li>
&lt;li>&lt;strong>Simple proxy, low budget → Helicone&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Strict vendor neutrality → OpenLLMetry/Traceloop&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Pydantic AI → Logfire&lt;/strong> (same team).&lt;/li>
&lt;/ul>
&lt;h3 id="strengths-and-weaknesses-of-the-instrumented-model">Strengths and weaknesses of the instrumented model&lt;/h3>
&lt;p>&lt;strong>Strengths&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Enormous depth&lt;/strong>: nested spans with all the context (chain steps, retrieval, embeddings, tool calls).&lt;/li>
&lt;li>&lt;strong>Semantic vocabulary&lt;/strong>: the SDK knows the domain (LLM, vector store, agent).&lt;/li>
&lt;li>&lt;strong>Maturity&lt;/strong>: three years of evolution, a rich ecosystem, ready-made dashboards.&lt;/li>
&lt;li>&lt;strong>Built-in evals&lt;/strong>: the top platforms combine tracing with evaluation (judge LLM, datasets, regression).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Weaknesses&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Requires control of the code&lt;/strong>: if you cannot instrument, it does not work.&lt;/li>
&lt;li>&lt;strong>Trust in the app&lt;/strong>: if the app reports badly or has a bug, so does the trace. It is not tamper-proof.&lt;/li>
&lt;li>&lt;strong>Coupling to the SDK&lt;/strong>: version changes in a library can break the instrumentation.&lt;/li>
&lt;li>&lt;strong>Uneven coverage&lt;/strong>: Python SDKs are mature; Go, Rust and JS are younger.&lt;/li>
&lt;/ul>
&lt;h2 id="the-zero-instrumentation-approach-agentsight">The zero-instrumentation approach: AgentSight&lt;/h2>
&lt;p>&lt;a href="https://github.com/eunomia-bpf/agentsight">AgentSight&lt;/a> is the project from the &lt;code>eunomia-bpf&lt;/code> group that champions the opposite approach. Its &lt;a href="https://arxiv.org/abs/2508.02736">paper on arxiv (2508.02736)&lt;/a>, presented at the &lt;em>Workshop on Practical Adoption Challenges of ML for Systems&lt;/em>, formalises the proposal. The premise is direct:&lt;/p>
&lt;blockquote>
&lt;p>&lt;em>Instead of instrumenting the agent, observe it at the system boundary.&lt;/em>&lt;/p>
&lt;/blockquote>
&lt;p>And &amp;ldquo;system boundary&amp;rdquo; means &lt;strong>the kernel boundary&lt;/strong>: the last point before a piece of data leaves the process towards the network or the filesystem. There, with eBPF, things are seen as they are, without the application being able to cooperate in hiding them.&lt;/p>
&lt;h3 id="architecture-three-planes">Architecture: three planes&lt;/h3>
&lt;p>AgentSight assembles three layers:&lt;/p>
&lt;p>&lt;strong>Plane 1 — SSL/TLS uprobes&lt;/strong>. eBPF can attach programs to functions in &lt;strong>userspace libraries&lt;/strong> (uprobes). The target functions are the encryption ones: &lt;code>SSL_write&lt;/code>, &lt;code>SSL_read&lt;/code> from OpenSSL/BoringSSL, and their equivalents in Rustls. AgentSight puts hooks on them that &lt;strong>capture the arguments&lt;/strong>: the &lt;strong>plaintext&lt;/strong> buffer the app passes to be encrypted, just before TLS processes it. On reception it does the symmetric thing: a hook after &lt;code>SSL_read&lt;/code> with the freshly decrypted plaintext. Result: AgentSight sees the complete content of any HTTPS request the app makes &lt;strong>without needing a man-in-the-middle, certificates or traffic decryption&lt;/strong>. The payload is plaintext because it was captured &lt;strong>before&lt;/strong> being encrypted.&lt;/p>
&lt;p>This works because uprobes are cheap (around 100 ns per invocation) and because apps use common TLS libraries. The few apps that implement their own TLS (rare in production) escape this hook; for those you need a different kprobe or manual instrumentation.&lt;/p>
&lt;p>&lt;strong>Plane 2 — Kernel events&lt;/strong>. In parallel, AgentSight observes relevant syscalls through tracepoints: &lt;code>execve&lt;/code> (which processes start), &lt;code>connect&lt;/code>/&lt;code>accept&lt;/code> (network), &lt;code>read&lt;/code>/&lt;code>write&lt;/code> with file descriptors (filesystem and stdio), &lt;code>unlink&lt;/code>, &lt;code>clone&lt;/code>. Any action by the agent that has an effect outside the process passes through here. This covers, among other things, &lt;strong>shell commands executed by the agent&lt;/strong>: if a Claude Code agent decides to run &lt;code>rm -rf&lt;/code> to &amp;ldquo;clean up the project&amp;rdquo;, the &lt;code>execve&lt;/code> is seen even if the LLM API does not report it.&lt;/p>
&lt;p>&lt;strong>Plane 3 — Correlation engine&lt;/strong>. The two previous planes produce streams of asynchronous events. AgentSight has a userspace component that &lt;strong>correlates them causally cross-process&lt;/strong>: an outgoing HTTP request with &lt;code>bash -c rm -rf&lt;/code> can be correlated with the previous LLM response that suggested it, through PIDs, timings and heuristics. The paper mentions the optional use of &lt;strong>a secondary LLM&lt;/strong> (Anthropic Claude, for instance) that analyses the sequence of events and produces semantic alerts: &amp;ldquo;the agent responded with a tool call that was not in the whitelist&amp;rdquo;, &amp;ldquo;the reasoning chain has gone 47 iterations without converging&amp;rdquo;.&lt;/p>
&lt;h3 id="stdiocap-capturing-the-stdio-of-local-mcp-servers">&lt;code>stdiocap&lt;/code>: capturing the stdio of local MCP servers&lt;/h3>
&lt;p>One specific piece that deserves its own mention is &lt;code>stdiocap&lt;/code>, a separate BPF tool included in the repo. The &lt;strong>Model Context Protocol (MCP)&lt;/strong>, popularised by Anthropic in 2024 and mainstream in 2025-2026, has two transport modes: HTTP/SSE (network) and &lt;strong>stdio&lt;/strong> (between the client and the server it starts as a subprocess). Local MCP servers, the ones that run on the same machine and are started by the client as children over pipes, communicate through stdin/stdout/stderr with JSON-RPC.&lt;/p>
&lt;p>&lt;code>stdiocap&lt;/code> hooks &lt;code>read&lt;/code>/&lt;code>write&lt;/code>/&lt;code>dup&lt;/code> on the stdin/stdout/stderr file descriptors of a target process and &lt;strong>records all the JSON-RPC traffic&lt;/strong> between the MCP client and server. It is the same idea as the SSL capture but for stdio: you observe the conversation without either the client or the server knowing. Typical use case: seeing which tools of the &lt;code>filesystem-mcp&lt;/code> MCP server a Claude Code agent has invoked in the last hour, what arguments it passed, what errors it received. Impossible with classic instrumentation (MCP servers are usually third-party binaries).&lt;/p>
&lt;h3 id="guarantees-tamper-proof-kernel-safety-3-overhead">Guarantees: tamper-proof, kernel safety, &amp;lt;3% overhead&lt;/h3>
&lt;p>Three properties make AgentSight interesting for production:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tamper-proof&lt;/strong>: the observation happens in the kernel (uprobes, syscalls). A malicious or compromised application cannot falsify what is seen. Compare with instrumentation: if the agent decides not to emit the span for its action, it does not appear in Langfuse. Here it has no choice.&lt;/li>
&lt;li>&lt;strong>Kernel safety&lt;/strong>: eBPF formally verifies that programs terminate and respect bounds checks. It cannot crash the kernel. The same as in the rest of the eBPF series.&lt;/li>
&lt;li>&lt;strong>&amp;lt;3% CPU overhead&lt;/strong> measured on real agent workloads (per the paper). The number compares favourably with SDK instrumentation, which typically adds 5-10% in intensive applications.&lt;/li>
&lt;/ul>
&lt;h3 id="what-it-detects-out-of-the-box">What it detects out of the box&lt;/h3>
&lt;p>The paper and the documentation highlight three classes of detection:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Real-time prompt injection&lt;/strong>: the correlation engine can apply rules or a detection model over the plaintext captured by the SSL uprobes. If the prompt contains suspicious patterns, &amp;ldquo;ignore all previous instructions&amp;rdquo;, a system prompt embedded in a user input, instructions to exfiltrate data, it raises an alert.&lt;/li>
&lt;li>&lt;strong>Reasoning loops that burn resources&lt;/strong>: agents that enter infinite loops calling tools without progressing. Detectable because the causal chain does not converge to a &amp;ldquo;final answer&amp;rdquo; and the tokens pile up. The correlation engine flags them.&lt;/li>
&lt;li>&lt;strong>Bottlenecks in multi-agent setups&lt;/strong>: when several agents coordinate, AgentSight sees the communication matrix between all of them and can detect agents that block while waiting, deadlocks, excessive fan-out.&lt;/li>
&lt;/ul>
&lt;h2 id="the-clash-and-the-coexistence">The clash and the coexistence&lt;/h2>
&lt;p>The two families look like competitors, but in reality they see different things and complement each other in production.&lt;/p>
&lt;h3 id="what-only-the-instrumented-one-sees">What only the instrumented one sees&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Internal agent variables&lt;/strong> that never reach the wire: the intermediate state of a LangChain chain, the values before passing them to a tool, how a prompt is built from a template with internal vars.&lt;/li>
&lt;li>&lt;strong>Deep semantic spans&lt;/strong>: &lt;code>retrieval &amp;gt; embed &amp;gt; vector_search &amp;gt; rerank &amp;gt; format_context &amp;gt; prompt_template &amp;gt; llm&lt;/code>. AgentSight sees only the final call to the LLM; the path taken to build it is invisible.&lt;/li>
&lt;li>&lt;strong>Evaluations&lt;/strong>: scoring of answers, judge LLMs, quality regression. This lives only in instrumented platforms.&lt;/li>
&lt;/ul>
&lt;h3 id="what-only-ebpf-sees">What only eBPF sees&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Opaque binaries&lt;/strong>: Claude Code, Cursor, Gemini CLI, third-party agents. You do not have the code; you cannot instrument them. Only eBPF sees them.&lt;/li>
&lt;li>&lt;strong>System-level actions&lt;/strong>: the agent decides to run &lt;code>git push --force&lt;/code> or &lt;code>kubectl delete&lt;/code>. The action is seen in the &lt;code>execve&lt;/code>. The agent&amp;rsquo;s instrumentation may not report it (especially if it was a command the agent generated as output without going through an explicit &amp;ldquo;tool&amp;rdquo;).&lt;/li>
&lt;li>&lt;strong>Tamper-proof audit&lt;/strong>: for regulatory compliance (HIPAA, SOC2, NIS2), having observation the app cannot dodge has formal value. eBPF provides it.&lt;/li>
&lt;li>&lt;strong>Local MCP servers over stdio&lt;/strong>: invisible to classic instrumentation unless each server emits its own spans (rare).&lt;/li>
&lt;/ul>
&lt;h3 id="what-both-see-complementarily">What both see, complementarily&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Prompts and completions&lt;/strong>: the instrumented approach emits them with rich metadata; eBPF captures them off the wire. A perfect cross-check for spotting discrepancies.&lt;/li>
&lt;li>&lt;strong>Calls to external APIs&lt;/strong>: APM flags it; eBPF confirms it at kernel level.&lt;/li>
&lt;li>&lt;strong>Latency&lt;/strong>: APM per span; eBPF measures RTT at TCP level and network connectivity.&lt;/li>
&lt;/ul>
&lt;h3 id="decision-matrix">Decision matrix&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Case&lt;/th>
&lt;th>Instrumented&lt;/th>
&lt;th>eBPF (AgentSight)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Your own app with LangChain&lt;/td>
&lt;td>&lt;strong>Yes, first&lt;/strong>&lt;/td>
&lt;td>Optional&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Your own multi-framework app&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>Optional&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Third-party binary (Claude Code, Cursor)&lt;/td>
&lt;td>&lt;strong>Does not work&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Yes, the only path&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tamper-proof regulatory compliance&lt;/td>
&lt;td>Insufficient&lt;/td>
&lt;td>&lt;strong>Yes, required&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Zero-trust multi-tenant&lt;/td>
&lt;td>Insufficient&lt;/td>
&lt;td>&lt;strong>Yes, required&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Local MCP servers (stdio)&lt;/td>
&lt;td>Difficult&lt;/td>
&lt;td>&lt;strong>Yes, with stdiocap&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Answer quality evaluation&lt;/td>
&lt;td>&lt;strong>Yes, required&lt;/strong>&lt;/td>
&lt;td>No (out of scope)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Depth of the internal chain&lt;/td>
&lt;td>&lt;strong>Yes, required&lt;/strong>&lt;/td>
&lt;td>No (a black box for AgentSight)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reasoning loop detection&lt;/td>
&lt;td>Possible with plumbing&lt;/td>
&lt;td>&lt;strong>Yes, built in&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Real-time prompt injection&lt;/td>
&lt;td>Possible (post-processed)&lt;/td>
&lt;td>&lt;strong>Yes, in stream&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The natural conclusion: &lt;strong>for your own apps, instrumented; for opaque binaries or compliance, eBPF; for everything that matters, both&lt;/strong>.&lt;/p>
&lt;h2 id="2026-reference-architecture">2026 reference architecture&lt;/h2>
&lt;p>Four recipes that cover the bulk of real cases:&lt;/p>
&lt;h3 id="setup-a--your-own-application-with-langchain-or-similar">Setup A — Your own application with LangChain or similar&lt;/h3>
&lt;p>Needs: depth, evals, a team comfortable with SDKs.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Langfuse self-host&lt;/strong> or &lt;strong>LangSmith cloud&lt;/strong> as the backend.&lt;/li>
&lt;li>&lt;strong>OpenLLMetry SDK&lt;/strong> or &lt;strong>LangSmith SDK&lt;/strong> instrumenting the code.&lt;/li>
&lt;li>&lt;strong>OpenTelemetry Collector&lt;/strong> between the app and the backend for routing flexibility (to Langfuse + Tempo + Loki, for instance).&lt;/li>
&lt;li>&lt;strong>Hubble&lt;/strong> for the network layer in the cluster (inter-pod latency, drop attribution).&lt;/li>
&lt;/ul>
&lt;h3 id="setup-b--productionising-an-opaque-binary-claude-code-gemini-cli">Setup B — Productionising an opaque binary (Claude Code, Gemini CLI)&lt;/h3>
&lt;p>Needs: observe without touching, audit, control cost.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>AgentSight&lt;/strong> deployed as a DaemonSet over the cluster (or standalone on the node).&lt;/li>
&lt;li>&lt;strong>Grafana with dashboards&lt;/strong> fed by AgentSight&amp;rsquo;s metrics.&lt;/li>
&lt;li>&lt;strong>An OTLP exporter&lt;/strong> from AgentSight to an OTel backend (Tempo, Jaeger). The spans will use the GenAI semantic conventions once they are fully standardised.&lt;/li>
&lt;li>&lt;strong>Tetragon&lt;/strong> optionally for policy over what the agent is allowed to run (Sigkill if it tries &lt;code>rm -rf&lt;/code> or similar).&lt;/li>
&lt;/ul>
&lt;h3 id="setup-c--zero-trust-multi-tenant-platform">Setup C — Zero-trust multi-tenant platform&lt;/h3>
&lt;p>Needs: agents from different clients running on the same cluster, mandatory audit, nobody trusts anybody.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>AgentSight&lt;/strong> as the tamper-proof audit layer. Compliance requires it.&lt;/li>
&lt;li>&lt;strong>Langfuse multi-tenant&lt;/strong> for the clients that do instrument.&lt;/li>
&lt;li>&lt;strong>Tetragon&lt;/strong> with &lt;code>TracingPolicyNamespaced&lt;/code> per tenant (different policies per namespace).&lt;/li>
&lt;li>&lt;strong>Hubble&lt;/strong> with persistent flow logs for forensics.&lt;/li>
&lt;li>&lt;strong>Cilium NetworkPolicy&lt;/strong> to isolate tenants from each other on the network.&lt;/li>
&lt;/ul>
&lt;h3 id="setup-d--a-local-mcp-server-on-a-workstation">Setup D — A local MCP server on a workstation&lt;/h3>
&lt;p>Needs: seeing what an agent does with a stdio MCP server.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>AgentSight stdiocap&lt;/strong> pointed at the PID of the client or the server.&lt;/li>
&lt;li>Complete JSON-RPC capture to a file or to an OTLP endpoint.&lt;/li>
&lt;li>Visualisation: Grafana, or simply &lt;code>jq&lt;/code> over the log.&lt;/li>
&lt;/ul>
&lt;p>A real use case: if you are integrating an MCP server of your own and want to see what tool calls a Claude Code or Cursor agent makes to your server, &lt;code>stdiocap&lt;/code> is the cleanest way. You need to modify neither client nor server.&lt;/p>
&lt;h2 id="operational-traps">Operational traps&lt;/h2>
&lt;h3 id="sensitive-data-in-prompts-instrumented">Sensitive data in prompts (instrumented)&lt;/h3>
&lt;p>By default, Langfuse, LangSmith and similar tools capture &lt;strong>the complete content&lt;/strong> of prompts and completions. If your app processes PII, secrets or medical data, &lt;strong>that goes to your observability backend&lt;/strong>. Configuring &lt;strong>redaction&lt;/strong> or &lt;strong>content opt-out&lt;/strong> before going to production is mandatory. OTel GenAI has specific flags (&lt;code>OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=false&lt;/code>) to avoid it.&lt;/p>
&lt;h3 id="sensitive-data-in-prompts-agentsight">Sensitive data in prompts (AgentSight)&lt;/h3>
&lt;p>The same problem, worse: AgentSight captures &lt;strong>literally what goes to the wire&lt;/strong>, in plaintext. If the agent talked to &lt;code>api.openai.com&lt;/code> with a prompt containing sensitive data, AgentSight has that plaintext. You have to encrypt or redact before storing.&lt;/p>
&lt;h3 id="pinned-certificates-or-non-standard-tls">Pinned certificates or non-standard TLS&lt;/h3>
&lt;p>Some high-security apps do certificate pinning or use unconventional TLS implementations (Go&amp;rsquo;s &lt;code>crypto/tls&lt;/code>, custom BoringSSL). In those cases, the uprobes on &lt;code>libssl&lt;/code> do not cover them. AgentSight detects when it cannot observe and reports the gap; you still have to add specific hooks for the alternative SDK.&lt;/p>
&lt;h3 id="token-volume-and-storage">Token volume and storage&lt;/h3>
&lt;p>An application with medium traffic can generate &lt;strong>millions of tokens a day&lt;/strong>. If you store them all in Langfuse or Phoenix with long retention, the database grows fast. Strategies: aggressive sampling, short retention for normal sessions and long retention only for errors/anomalies, content redaction and keeping only metadata.&lt;/p>
&lt;h3 id="tracing-with-sampling-and-consistency">Tracing with sampling and consistency&lt;/h3>
&lt;p>To cut cost, many installations sample: only 1 in every N traces is persisted. &lt;strong>Be careful with inconsistent sampling&lt;/strong>: a trace can carry several spans across multiple services, and if the sampling decision is taken per span, you end up with incomplete traces. OTel has &lt;strong>head sampling&lt;/strong> (in the SDK, at the start), which is consistent, and &lt;strong>tail sampling&lt;/strong> (in the collector, at the end), which allows finer rules. For LLM, tail sampling is ideal: sample everything, discard only the &amp;ldquo;normal&amp;rdquo; traces and keep the ones with errors, high latency or high cost.&lt;/p>
&lt;h3 id="multi-agent-and-trace-propagation">Multi-agent and trace propagation&lt;/h3>
&lt;p>When agent A calls agent B, you have to &lt;strong>propagate the trace context&lt;/strong> (W3C Trace Context headers) so it shows up as a single tree. If you do not, you see two disconnected traces. Modern platforms do it automatically with &lt;code>inject&lt;/code>/&lt;code>extract&lt;/code>, but if your transport between agents is custom (over Redis pub/sub, over a DB), you have to propagate by hand.&lt;/p>
&lt;h3 id="the-cost-of-uprobes-on-critical-libraries">The cost of uprobes on critical libraries&lt;/h3>
&lt;p>Hooking &lt;code>libssl&lt;/code> adds around 100 ns per invocation. On extreme TLS traffic loads (tens of thousands of connections/s per core), that adds up. AgentSight keeps it under 3% on typical agent workloads (which are chatty but not networking-intensive). If your use were sniffing all the HTTPS on the node, it could hurt more.&lt;/p>
&lt;h2 id="what-we-have-not-covered-next-series">What we have not covered (next series)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Evals&lt;/strong>: the next layer after tracing. Phoenix, Langfuse, LangSmith and company offer answer evaluation (judge LLM, datasets, regression). It is a world of its own.&lt;/li>
&lt;li>&lt;strong>Guardrails and safety&lt;/strong>: NeMo Guardrails, Llama Guard, Llama Prompt Guard, evaluators specific to prompt injection and jailbreaks.&lt;/li>
&lt;li>&lt;strong>Deep MCP server observability&lt;/strong>: how the OpenTelemetry GenAI conventions are being extended to MCP servers for trace-aware tools.&lt;/li>
&lt;li>&lt;strong>eBPF + on-device inference&lt;/strong>: when the LLM runs locally through vLLM or llama.cpp, the uprobes can see the output token queue BEFORE it goes to the client. New territory.&lt;/li>
&lt;li>&lt;strong>Statistical analysis of agent flows&lt;/strong>: detecting drift, outliers, patterns that indicate degradation.&lt;/li>
&lt;/ul>
&lt;h2 id="closing-the-ebpf-series">Closing the eBPF series&lt;/h2>
&lt;p>This series of four articles has travelled eBPF from first principles to the 2026 frontier:&lt;/p>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a> — what eBPF is, networking hooks, how Cilium skips the TCP/IP stack, BGP Control Plane v2.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon: runtime security&lt;/a> — observability and enforcement of processes in the kernel.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble: network observability&lt;/a> — L3-L7 flow logs and the frontier with AI agents.&lt;/li>
&lt;li>&lt;strong>This one&lt;/strong> — AgentSight, LLM tracing, instrumented vs zero-instrumentation.&lt;/li>
&lt;/ol>
&lt;p>If you have got this far you have the map to sit down with a platform, security or AI team in 2026 and recognise what each piece does, what problem it solves and where to start. That whole stack, Cilium for CNI and BGP, Tetragon for runtime security, Hubble for network observability, AgentSight for AI agents, sharing eBPF as a common substrate, Cloud Native governance and OpenTelemetry vocabulary. It is the clean architecture the industry asked for a decade ago and that finally exists.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>AgentSight:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/eunomia-bpf/agentsight">AgentSight GitHub (eunomia-bpf)&lt;/a> — the project.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2508.02736">AgentSight: System-Level Observability for AI Agents Using eBPF (arxiv 2508.02736)&lt;/a> — the formal paper.&lt;/li>
&lt;li>&lt;a href="https://dl.acm.org/doi/10.1145/3766882.3767169">AgentSight ACM workshop publication&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://eunomia.dev/blog/2025/08/26/agentsight-keeping-your-ai-agents-under-control-with-ebpf-powered-system-observability/">AgentSight blog post (eunomia.dev)&lt;/a> — accessible description.&lt;/li>
&lt;/ul>
&lt;p>OpenTelemetry GenAI semantic conventions:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">OpenTelemetry — Semantic conventions for generative AI systems&lt;/a> — official reference.&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/">Semantic conventions for generative client AI spans&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/">Semantic conventions for generative AI metrics&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/blog/2026/genai-observability/">Inside the LLM Call: GenAI Observability with OpenTelemetry (OTel blog 2026)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/open-telemetry/semantic-conventions/issues/2664">Multi-agent Semantic Conventions (GitHub issue #2664)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Instrumented platforms:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://langfuse.com/">Langfuse&lt;/a> — MIT, self-host + cloud.&lt;/li>
&lt;li>&lt;a href="https://www.langchain.com/langsmith">LangSmith&lt;/a> — LangChain team.&lt;/li>
&lt;li>&lt;a href="https://phoenix.arize.com/">Arize Phoenix&lt;/a> — OSS, OTel-native.&lt;/li>
&lt;li>&lt;a href="https://www.helicone.ai/">Helicone&lt;/a> — simple proxy.&lt;/li>
&lt;li>&lt;a href="https://github.com/traceloop/openllmetry">OpenLLMetry (Traceloop)&lt;/a> — Apache 2.0, OTel SDK.&lt;/li>
&lt;li>&lt;a href="https://pydantic.dev/docs/logfire/get-started/ai-observability/">Pydantic Logfire — AI observability&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>2026 comparisons:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.braintrust.dev/articles/langfuse-alternatives-2026">Langfuse alternatives 2026 (Braintrust)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.braintrust.dev/articles/best-llm-tracing-tools-2026">7 best LLM tracing tools for multi-agent AI systems (2026)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://medium.com/@kanerika/llmops-observability-langsmith-vs-arize-vs-langfuse-vs-w-b-f1baeabd1bbf">LLMOps Observability: LangSmith vs Arize vs Langfuse vs W&amp;amp;B&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.firecrawl.dev/blog/best-llm-observability-tools">Best LLM Observability Tools in 2026 (Firecrawl)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.spheron.network/blog/llm-observability-gpu-cloud-langfuse-arize-phoenix-helicone/">LLM Observability on GPU Cloud (Spheron 2026 guide)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Cross-references from the series:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon: runtime security&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble: network observability&lt;/a>.&lt;/li>
&lt;li>LLM inference series: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM Operators on K8s&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>