<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Mlflow on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/mlflow/</link><description>Recent content in Mlflow on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Sun, 26 Jul 2026 19:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/mlflow/index.xml" rel="self" type="application/rss+xml"/><item><title>Model chain of trust (2/4): where the bytes live — registry, OCI artefacts and distribution to the GPU node</title><link>https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/</link><pubDate>Sun, 26 Jul 2026 19:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/</guid><description>&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">first article in the series&lt;/a> we described the control plane: who declares that an inference endpoint exists, who reconciles it and what contract the clients speak. But an &lt;code>InferenceService&lt;/code> is no more than a promise until somebody puts 140 GB of weights on the filesystem the container sees. This second article is about exactly that: where those bytes come from and by what route they reach the GPU&amp;rsquo;s memory.&lt;/p>
&lt;p>It is a subject the blog has touched around the edges, in the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">cold start series&lt;/a>, in &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>, in the &lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">catalogue of OSS LLMOps tools&lt;/a>, but never head on. It is time to, because almost every on-premise inference platform that gets audited shares the same original defect, and it is conceptual before it is about performance: they conflate three things that live in different places and fail in different ways.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Three planes, not one.&lt;/strong> &lt;em>Model registry&lt;/em> (metadata, versions, lineage, promotion) ≠ &lt;em>artefact repository&lt;/em> (the bytes, addressed by digest) ≠ &lt;em>deployment storage&lt;/em> (what the pod mounts at &lt;code>/mnt/models&lt;/code>). Mixing them is what breaks reproducibility.&lt;/li>
&lt;li>&lt;strong>The anti-pattern&lt;/strong>: a Hugging Face token in the pod and a download from the internet on every start-up. No immutability, no cache, no audit trail, no air-gap and cold starts measured in minutes.&lt;/li>
&lt;li>&lt;strong>The OCI artefact is today&amp;rsquo;s pragmatic answer&lt;/strong>: OCI Image Spec 1.1 (March 2024) brought &lt;code>artifactType&lt;/code>, &lt;code>subject&lt;/code> and the Referrers API; ORAS v1.3.0 (October 2025) added &lt;code>backup&lt;/code>/&lt;code>restore&lt;/code> for air-gap; ModelPack (CNCF sandbox since June 2025) standardises the mediaTypes for weights.&lt;/li>
&lt;li>&lt;strong>Kubernetes already knows how to mount OCI artefacts&lt;/strong>: the &lt;em>image volume source&lt;/em> (KEP-4639) reached GA in v1.36 (22 April 2026). It is the most operationally relevant change of the year for model distribution.&lt;/li>
&lt;li>&lt;strong>The real bottleneck is almost never the GPU.&lt;/strong> 140 GB over a saturated 10 Gb/s link is about 112 s; over Gen4 NVMe about 20 s; over PCIe Gen5 about 2-3 s. Optimising the &lt;em>loader&lt;/em> without fixing the network is optimising the wrong link.&lt;/li>
&lt;li>A &lt;em>model registry&lt;/em> (MLflow, Kubeflow Hub) does not store the bytes: it stores the record card and a pointer. If you were expecting it to solve your distribution, you bought the wrong tool.&lt;/li>
&lt;/ul>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>The simile is a serious &lt;strong>historical archive&lt;/strong>, with three things a distracted visitor constantly confuses.&lt;/p>
&lt;p>The &lt;strong>catalogue&lt;/strong>: record cards with a shelfmark, author, date, provenance, conservation state and consultation restrictions. The card weighs grams and describes something that weighs kilos. The &lt;strong>repository&lt;/strong>: the compact shelving in the basement where the physical boxes are, with access control, temperature control and an inventory that balances to the gram; it knows nothing about &lt;em>why&lt;/em> a document matters, but it guarantees that box 4711 still contains exactly what it contained. And the &lt;strong>reading room table&lt;/strong>: where the researcher has the document open in front of them. A temporary and expensive place, from which the document goes back to the repository when the session ends.&lt;/p>
&lt;p>The &lt;em>model registry&lt;/em> is the catalogue. The artefact repository is the repository. The GPU node with the model in HBM is the reading room table. Anyone who says &amp;ldquo;we have set up a model registry&amp;rdquo; and what they have set up is MLflow has set up the catalogue and left the repository unbuilt: the boxes are still in somebody&amp;rsquo;s car boot. Putting the model inside the container image is the equivalent of putting the document inside the table: every time the table changes, the document moves. And anyone who downloads from the internet on every start-up has no repository: they order the document by courier from another city every time a researcher sits down.&lt;/p>
&lt;p>We will come back to the archive later, with the weeding that deletes boxes that are still referenced, the trolley that carries things between the basement and the reading room, and the certified reproduction sent to the isolated site.&lt;/p>
&lt;h2 id="three-planes-that-are-not-the-same">Three planes that are not the same&lt;/h2>
&lt;p>Before discussing tools you have to fix the vocabulary, because a good part of the architecture discussions at this layer are really discussions about nomenclature.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Plane&lt;/th>
&lt;th>What it stores&lt;/th>
&lt;th>Unit&lt;/th>
&lt;th>Question it answers&lt;/th>
&lt;th>OSS examples&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Model registry&lt;/strong>&lt;/td>
&lt;td>Metadata, logical versions, lineage, promotion state, evaluation metrics&lt;/td>
&lt;td>The model&amp;rsquo;s &lt;em>record card&lt;/em> (KB)&lt;/td>
&lt;td>Which version is approved for production and where did it come from?&lt;/td>
&lt;td>MLflow, Kubeflow Hub, ClearML&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Artefact repository&lt;/strong>&lt;/td>
&lt;td>The bytes, addressed by digest, immutable&lt;/td>
&lt;td>The &lt;em>blob&lt;/em> (GB-TB)&lt;/td>
&lt;td>What exactly are the bytes of that version?&lt;/td>
&lt;td>Harbor, &lt;code>distribution&lt;/code>, Zot, MinIO, lakeFS&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Deployment storage&lt;/strong>&lt;/td>
&lt;td>The copy the pod sees mounted&lt;/td>
&lt;td>The &lt;em>volume&lt;/em> (&lt;code>/mnt/models&lt;/code>)&lt;/td>
&lt;td>What is the inference process reading right now?&lt;/td>
&lt;td>PVC, image volume, emptyDir + NVMe cache&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The three layers have different life cycles. A record card lives forever (audit); a blob, for as long as some version references it (retention); a volume, for as long as the pod lives. Implemented as a single thing, it inherits the worst of the three: if the registry is also the deployment storage, deleting an old version takes down a pod in production; if the deployment storage is also the canonical reference, there is no way to answer what was served last Tuesday.&lt;/p>
&lt;p>Hence the operational rule that gets written on the wall: the source of truth for the bytes is a digest, not a tag and not a path. Everything else (tags, aliases, PVC paths) is a mutable pointer that resolves to that digest at a given moment. Article 3/4 builds all of its provenance verification on that premise.&lt;/p>
&lt;h2 id="the-anti-pattern-we-start-from">The anti-pattern we start from&lt;/h2>
&lt;p>This is, by a distance, the most widespread pattern in on-premise deployments that started as a pilot and stayed that way:&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">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-anti-patron&lt;/span>&lt;span class="w">
&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">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">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.11.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;--model&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;meta-llama/Llama-3.3-70B-Instruct&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">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HF_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">valueFrom&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">secretKeyRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&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">hf-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">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">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">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It works and it serves tokens. And it is unacceptable on a sovereign platform for five independent reasons, any one of which would be enough:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>An external dependency on the critical start-up path.&lt;/strong> A &lt;code>Deployment&lt;/code> that cannot recover from a failure without outbound internet access carries a third-party dependency that nobody has declared in the SLA.&lt;/li>
&lt;li>&lt;strong>Zero immutability.&lt;/strong> &lt;code>meta-llama/Llama-3.3-70B-Instruct&lt;/code> is a mutable reference, and a repository&amp;rsquo;s default branch changes (tokenizer fixes, chat templates). Two replicas of the same &lt;code>Deployment&lt;/code> started a week apart can serve different bytes, and nothing in the cluster says so.&lt;/li>
&lt;li>&lt;strong>Zero coordinated cache and zero audit trail.&lt;/strong> Every pod downloads its own copy: eight replicas of a 140 GB model is 1.1 TB of outbound traffic per rolling update. And no internal record remains of what was downloaded, with which digest or who authorised it; faced with an ENS or ISO 42001 auditor, the answer is &amp;ldquo;we got it off the internet&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Incompatible with air-gap.&lt;/strong> The whole design collapses when the platform has to be replicated in an isolated environment, which is the defence and healthcare case dealt with in &lt;a href="https://blog.lo0.es/en/posts/infrastructure-defence-ai-compliance/">regulatory compliance infrastructure&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Cold start measured in minutes.&lt;/strong> Downloading 140 GB over a 1 Gb/s outbound link (125 MB/s effective, being generous) is about 19 minutes of transfer alone, before touching the disk or the GPU; with 10 Gb/s dedicated and saturated, about 112 seconds. In a load-driven scaling event, that is service latency.&lt;/li>
&lt;/ol>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">blog&amp;rsquo;s cold start series&lt;/a> explores how to trim the disk-to-HBM leg. What this article adds is that if the first leg is the internet, the rest of the optimisation is irrelevant.&lt;/p>
&lt;h2 id="the-model-as-an-oci-artefact">The model as an OCI artefact&lt;/h2>
&lt;p>The pragmatic answer is boring, and that is why it works: the model is stored in the same registry where the containers are already stored. There is already access control, cross-site replication, scanning, quotas, audit and a team that knows how to operate it.&lt;/p>
&lt;h3 id="what-oci-11-made-possible">What OCI 1.1 made possible&lt;/h3>
&lt;p>For years, putting things that were not container images into an OCI registry was a bodge. The &lt;strong>OCI Image Spec 1.1 and Distribution Spec 1.1&lt;/strong> specifications, published on 13 March 2024, turned it into a first-class use case with three pieces:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>artifactType&lt;/code>&lt;/strong> in the manifest: it explicitly declares &amp;ldquo;this is not a runnable image, it is an artefact of type X&amp;rdquo;. Previously this was encoded by abusing the config &lt;code>mediaType&lt;/code>, which is why even today there are registries that show a model as a broken image.&lt;/li>
&lt;li>&lt;strong>&lt;code>subject&lt;/code>&lt;/strong>: a manifest can declare that it &lt;em>refers to&lt;/em> another manifest. That is what lets you hang a signature, an SBOM or an attestation off a model without modifying it and without changing its digest.&lt;/li>
&lt;li>&lt;strong>Referrers API&lt;/strong> (&lt;code>GET /v2/&amp;lt;repo&amp;gt;/referrers/&amp;lt;digest&amp;gt;&lt;/code>): the reverse query, &amp;ldquo;give me everything that points at this digest&amp;rdquo;, with a tag-based &lt;em>fallback&lt;/em> for registries that do not implement it yet.&lt;/li>
&lt;/ul>
&lt;p>Article 3/4 builds &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> on top of these three pieces. Without them, verifying a model forces you to invent a convention of your own.&lt;/p>
&lt;h3 id="oras-the-generic-client">ORAS: the generic client&lt;/h3>
&lt;p>&lt;strong>ORAS&lt;/strong> (&lt;em>OCI Registry As Storage&lt;/em>) is the Swiss army knife for getting arbitrary artefacts in and out of an OCI registry. Version v1.3.0 was published on 6 October 2025, conformant with distribution-spec v1.1.1, and added three things relevant to models: &lt;code>backup&lt;/code>/&lt;code>restore&lt;/code> to a directory or tarball (key for air-gap), management of multi-platform indexes and &lt;code>--format&lt;/code> for structured output.&lt;/p>
&lt;p>One data point to calibrate maturity: ORAS entered the CNCF sandbox on 13 July 2021 and as of this article is still in sandbox. Five years at the entry level does not invalidate the tool, which is stable and the de facto reference, but it says something about the project&amp;rsquo;s governance that is worth weighing before turning it into a critical dependency with no plan B.&lt;/p>
&lt;p>Pushing a model with ORAS, with explicit content types:&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">oras push registro.interno/modelos/llama-3.3-70b:2026.07.1 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --artifact-type application/vnd.cncf.model.manifest.v1+json &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --annotation &lt;span class="s2">&amp;#34;org.opencontainers.image.created=2026-07-20T09:00:00Z&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> --annotation &lt;span class="s2">&amp;#34;es.ejemplo.modelo.precision=bf16&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> --annotation &lt;span class="s2">&amp;#34;es.ejemplo.modelo.origen=hf:meta-llama/Llama-3.3-70B-Instruct&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> config.json:application/vnd.cncf.model.weight.config.v1.raw &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> tokenizer.json:application/vnd.cncf.model.weight.config.v1.raw &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> model-00001-of-00030.safetensors:application/vnd.cncf.model.weight.v1.raw &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> model-00002-of-00030.safetensors:application/vnd.cncf.model.weight.v1.raw
&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"># Retrieve it, with a local blob cache so nothing already present is re-downloaded&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">ORAS_CACHE&lt;/span>&lt;span class="o">=&lt;/span>/var/cache/oras
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">oras pull registro.interno/modelos/llama-3.3-70b:2026.07.1 --output /srv/modelos/llama-70b
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">oras manifest fetch registro.interno/modelos/llama-3.3-70b:2026.07.1 --descriptor
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="modelpack-the-missing-convention">ModelPack: the missing convention&lt;/h3>
&lt;p>ORAS lets you invent the mediaTypes, with the predictable result that every organisation invents its own and nothing interoperates. &lt;strong>ModelPack&lt;/strong> is the attempt to standardise them, accepted into the CNCF sandbox in June 2025. On top of OCI Image Manifest 1.1 it defines:&lt;/p>
&lt;ul>
&lt;li>&lt;code>artifactType&lt;/code>: &lt;code>application/vnd.cncf.model.manifest.v1+json&lt;/code>&lt;/li>
&lt;li>config: &lt;code>application/vnd.cncf.model.config.v1+json&lt;/code>&lt;/li>
&lt;li>weight layers: &lt;code>application/vnd.cncf.model.weight.v1.raw&lt;/code> (and the &lt;code>.tar&lt;/code>, &lt;code>.tar+gzip&lt;/code>, &lt;code>.tar+zstd&lt;/code> variants)&lt;/li>
&lt;li>weight configuration, documentation, code and dataset layers following the same pattern&lt;/li>
&lt;/ul>
&lt;p>Its reference CLI is &lt;strong>modctl&lt;/strong>, which works with a declarative &lt;code>Modelfile&lt;/code>:&lt;/p>
&lt;pre tabindex="0">&lt;code>NAME llama-3.3-70b-instruct
ARCH transformer
FAMILY llama
FORMAT safetensors
PARAMSIZE 70
PRECISION bf16
CONFIG config.json
CONFIG tokenizer.json
MODEL *.safetensors
DOC *.md
&lt;/code>&lt;/pre>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">modctl build -t registro.interno/modelos/llama-3.3-70b:2026.07.1 -f Modelfile .
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">modctl push registro.interno/modelos/llama-3.3-70b:2026.07.1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">modctl pull registro.interno/modelos/llama-3.3-70b:2026.07.1 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --extract-dir /srv/modelos/llama-70b --extract-from-remote
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The alternative with more traction among users is &lt;strong>KitOps&lt;/strong> (&lt;em>ModelKit&lt;/em>), with Docker-like ergonomics and native integration with Hugging Face. As of this article there is no convergence: ModelPack aims at the enterprise case and at integration with runtimes and registries; KitOps and Docker Model Runner, at developer ergonomics. Picking one today is a bet; using &lt;code>artifactType&lt;/code> and explicit annotations instead of home-grown conventions is not.&lt;/p>
&lt;h3 id="how-a-140-gb-model-is-sliced-up">How a 140 GB model is sliced up&lt;/h3>
&lt;p>A large model is 20-40 safetensors shards plus half a dozen small configuration files, and the layering strategy matters:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>One layer per shard, uncompressed.&lt;/strong> Weights in &lt;code>bf16&lt;/code> or &lt;code>fp8&lt;/code> are incompressible in practice: gzip over safetensors burns CPU to save 2-3 %, and forces decompression on the node, adding a full pass through CPU and disk on the critical path. The right type is &lt;code>.raw&lt;/code>.&lt;/li>
&lt;li>&lt;strong>The small files in their own layer.&lt;/strong> &lt;code>config.json&lt;/code>, &lt;code>tokenizer.json&lt;/code> and the templates change far more often than the weights. If they go in the same layer as a 5 GB shard, every tokenizer fix invalidates 5 GB of cache on every node.&lt;/li>
&lt;li>&lt;strong>Layer size between 2 and 8 GB.&lt;/strong> Large enough not to pay per-blob overhead, small enough to parallelise and so that a network failure does not force you to retry 40 GB.&lt;/li>
&lt;/ul>
&lt;p>In practice three things hurt. The upload uses the distribution-spec&amp;rsquo;s &lt;em>chunked upload&lt;/em>, with historically uneven interoperability between implementations, so pushing 140 GB is an operation measured in tens of minutes that demands a decent link and retries. Deduplication is by layer digest, so two versions that differ in one shard share the rest only if those blobs are byte-for-byte identical, and any repackaging that alters metadata breaks it. And the default values of reverse proxies (timeouts, maximum body size, upload session lifetime) are calibrated for 1 GB images, not for 8 GB blobs.&lt;/p>
&lt;h2 id="harbor-as-the-on-premise-repository">Harbor as the on-premise repository&lt;/h2>
&lt;p>&lt;strong>Harbor&lt;/strong> is the reference OSS registry for on-premise: a CNCF graduated project since June 2020, with a healthy release cycle (the 2.15 series came out on 20 March 2026 and patch 2.15.2 on 2 July 2026) and support for the last three minors for about nine months each. What matters for models:&lt;/p>
&lt;p>&lt;strong>Cross-site replication.&lt;/strong> Push or pull rules, filtered by repository, tag and resource type, with manual, scheduled or event-driven triggering. For models, scheduled and within a window, with a &lt;code>label&lt;/code> marking which versions get replicated (see traps).&lt;/p>
&lt;p>&lt;strong>Proxy cache.&lt;/strong> Here you have to be precise, because a lot of misinformation circulates: Harbor&amp;rsquo;s proxy cache works against OCI registries (Harbor, Docker Hub, a &lt;code>distribution&lt;/code> registry, ECR, ACR, GCR, Quay and GHCR) and not against Hugging Face, which does not expose an OCI distribution API. If the idea was &amp;ldquo;I put Harbor in front of Hugging Face and that is it&amp;rdquo;, it does not work that way. The real options are (a) an ingestion task that pulls from Hugging Face in a staging environment and pushes to Harbor as an OCI artefact, the recommended pattern because it introduces an explicit control point, or (b) a P2P accelerator with native support for model repositories, such as Dragonfly. What the proxy cache does solve is mirroring the runtime base images (vLLM, TGI, TEI), which is no small thing: it creates a 7-day retention by default and the project is read-only.&lt;/p>
&lt;p>&lt;strong>Per-project quotas.&lt;/strong> In bytes, via the UI or the API:&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 -u admin:REDACTED -X PUT &lt;span class="s2">&amp;#34;https://registro.interno/api/v2.0/quotas/7&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;Content-Type: application/json&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;hard&amp;#34;: {&amp;#34;storage&amp;#34;: 21990232555520}}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Those 21990232555520 bytes are 20 TiB, &lt;em>tight&lt;/em> sizing, not generous, for a dozen models with three live versions each. Two warnings: the quota is checked when the manifest arrives, that is &lt;em>after&lt;/em> uploading the blobs, so a push that exceeds it has already consumed disk and network; and the accounting does not always reflect deduplication of blobs shared between projects.&lt;/p>
&lt;p>&lt;strong>Immutable tags.&lt;/strong> Per-project rules that prevent overwriting a tag matching a pattern. For models it is not optional: a rule making everything under &lt;code>modelos/**&lt;/code> with tag pattern &lt;code>2*&lt;/code> (date-based versions) immutable eliminates the entire class of &amp;ldquo;the tag changed underneath us&amp;rdquo; incidents.&lt;/p>
&lt;p>&lt;strong>Retention and GC.&lt;/strong> Per-project retention rules (last N versions, or those from the last N days) that &lt;em>mark&lt;/em> artefacts for deletion, and a separate &lt;em>garbage collector&lt;/em> that frees the unreferenced blobs. They are two different things, and deleting in the UI does not free disk: the weeding runs separately. Running GC with a concurrent push in flight is the classic recipe for deleting blobs that were about to be referenced.&lt;/p>
&lt;p>&lt;strong>Scanning.&lt;/strong> Trivy is integrated, with one caveat: scanning a weights artefact detects nothing relevant about the model. It is useful for the runtime images, which do execute code. Detecting dangerous formats, a PyTorch &lt;code>pickle&lt;/code> with arbitrary code instead of safetensors, requires specific tools, and that is the subject of article 3/4.&lt;/p>
&lt;h2 id="model-registries-proper">Model registries proper&lt;/h2>
&lt;p>Now the catalogue, with the uncomfortable claim up front: a model registry does not solve distribution.&lt;/p>
&lt;h3 id="mlflow">MLflow&lt;/h3>
&lt;p>&lt;strong>MLflow&lt;/strong> (LF AI &amp;amp; Data) is the de facto catalogue standard, with a mature 3.x series; 3.13.0 was published in June 2026, focusing on RBAC, an administration UI and trace retention. Its data model: &lt;em>registered model&lt;/em> (logical name) → &lt;em>model version&lt;/em> (incrementing integer) → &lt;em>aliases&lt;/em> and &lt;em>tags&lt;/em>.&lt;/p>
&lt;p>The important conceptual change: &lt;em>stages&lt;/em> (&lt;code>Staging&lt;/code>, &lt;code>Production&lt;/code>, &lt;code>Archived&lt;/code>) have been deprecated since MLflow 2.9.0 and are replaced by &lt;em>aliases&lt;/em> and &lt;em>tags&lt;/em>, because a fixed enumeration is too inflexible to express real MLOps flows. An alias is a named mutable pointer:&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">mlflow&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">MlflowClient&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">MlflowClient&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">set_registered_model_alias&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;llama-3.3-70b-soberano&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;champion&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">7&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_model_version_tag&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;llama-3.3-70b-soberano&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;7&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;estado_validacion&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;aprobado_ens_medio&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">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_model_version_tag&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;llama-3.3-70b-soberano&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;7&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;oci_digest&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;sha256:9f2c...&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Consumption happens through the alias URI: &lt;code>models:/llama-3.3-70b-soberano@champion&lt;/code>.&lt;/p>
&lt;p>What MLflow gives you: lineage back to the &lt;em>run&lt;/em> that produced the model, evaluation metrics attached to the version, a shared promotion language and an API to hang approvals off. What it does &lt;em>not&lt;/em> give you: it is not a CDN and it does not manage distribution to nodes, and its &lt;em>artifact store&lt;/em> (S3/MinIO/NFS) provides neither immutability by digest, nor Referrers, nor cross-site replication. The healthy pattern is the one in the snippet: the catalogue stores the OCI digest as a tag on the version and points at the repository, instead of trying to be the repository.&lt;/p>
&lt;h3 id="kubeflow-hub-formerly-model-registry">Kubeflow Hub (formerly Model Registry)&lt;/h3>
&lt;p>The &lt;strong>Kubeflow Model Registry&lt;/strong> was renamed &lt;strong>Kubeflow Hub&lt;/strong> and unified two functions: the registry itself and a federated &lt;em>Model Catalog&lt;/em> that discovers external models (YAML, Hugging Face), with lineage across data, code and models, metadata and state-based promotion.&lt;/p>
&lt;p>The honest assessment: as of this article the component is still in the 0.3.x series and marked Alpha under Kubeflow&amp;rsquo;s versioning policy, with limited support. Interesting if you already run the full Kubeflow and want a single console; it is not yet the piece on which to build model governance for a sovereign platform in production. The rename in the middle of 0.3.x is, moreover, a sign that the API is still moving.&lt;/p>
&lt;h3 id="comparison-table-by-function">Comparison table by function&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Function&lt;/th>
&lt;th>OCI registry (Harbor + ORAS/ModelPack)&lt;/th>
&lt;th>MLflow Registry&lt;/th>
&lt;th>Kubeflow Hub&lt;/th>
&lt;th>lakeFS / DVC&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Stores the bytes&lt;/strong>&lt;/td>
&lt;td>Yes, by digest, immutable&lt;/td>
&lt;td>No (pointer to an artifact store)&lt;/td>
&lt;td>No (pointer)&lt;/td>
&lt;td>Yes (data and versions)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Lineage to data/code&lt;/strong>&lt;/td>
&lt;td>Only via annotations&lt;/td>
&lt;td>Yes, to the &lt;em>run&lt;/em>&lt;/td>
&lt;td>Yes, explicit&lt;/td>
&lt;td>Yes, for datasets&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Rich metadata&lt;/strong>&lt;/td>
&lt;td>Annotations and JSON config&lt;/td>
&lt;td>Yes, typed&lt;/td>
&lt;td>Yes, typed&lt;/td>
&lt;td>Partial&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Promotion / approval&lt;/strong>&lt;/td>
&lt;td>By convention (tags, labels)&lt;/td>
&lt;td>Aliases and tags&lt;/td>
&lt;td>States&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Signing and attestations&lt;/strong>&lt;/td>
&lt;td>Yes, native (&lt;code>subject&lt;/code> + Referrers)&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cross-site replication&lt;/strong>&lt;/td>
&lt;td>Yes, native&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Partial&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Standard API&lt;/strong>&lt;/td>
&lt;td>OCI Distribution 1.1&lt;/td>
&lt;td>Its own REST&lt;/td>
&lt;td>Its own REST&lt;/td>
&lt;td>S3/Git-like&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Air-gap&lt;/strong>&lt;/td>
&lt;td>Yes (&lt;code>oras backup&lt;/code>/&lt;code>restore&lt;/code>, &lt;code>skopeo&lt;/code>)&lt;/td>
&lt;td>Manual&lt;/td>
&lt;td>Manual&lt;/td>
&lt;td>Manual&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Maintainer&lt;/strong>&lt;/td>
&lt;td>CNCF (Harbor graduated; ORAS and ModelPack sandbox)&lt;/td>
&lt;td>LF AI &amp;amp; Data&lt;/td>
&lt;td>Kubeflow (CNCF)&lt;/td>
&lt;td>Commercial OSS&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Real maturity&lt;/strong>&lt;/td>
&lt;td>High (registry), medium (model convention)&lt;/td>
&lt;td>High&lt;/td>
&lt;td>Alpha&lt;/td>
&lt;td>High&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The thesis of the table: the columns are complementary, not alternatives. The OCI registry is the repository; MLflow, the catalogue; lakeFS or DVC version the dataset that everything originates from, as detailed in &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;/p>
&lt;h2 id="the-hot-path-from-the-registry-to-hbm">The hot path: from the registry to HBM&lt;/h2>
&lt;div class="diagram" style="max-width:860px;margin:1rem auto;">
&lt;svg viewBox="0 0 860 340" role="img" aria-label="The three planes of a model's distribution chain and the hot path from the OCI registry to the GPU's HBM memory" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.4}.dsh{fill:none;stroke:currentColor;stroke-width:1.4;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.4;marker-end:url(#am2)}&lt;/style>
&lt;defs>&lt;marker id="am2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="430" y="20" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Three planes and the hot path to the GPU&lt;/text>
&lt;rect class="dsh" x="18" y="38" width="200" height="86" rx="5"/>
&lt;text x="118" y="58" text-anchor="middle" class="tl">Catalogue plane&lt;/text>
&lt;text x="118" y="76" text-anchor="middle" class="ts">MLflow / Kubeflow Hub&lt;/text>
&lt;text x="118" y="92" text-anchor="middle" class="ts">version, alias, lineage&lt;/text>
&lt;text x="118" y="112" text-anchor="middle" class="ts">stores the DIGEST, not the bytes&lt;/text>
&lt;path class="ar" d="M218,81 L268,81"/>
&lt;rect class="bx" x="268" y="38" width="210" height="86" rx="5"/>
&lt;text x="373" y="58" text-anchor="middle" class="tl">Repository plane&lt;/text>
&lt;text x="373" y="76" text-anchor="middle" class="ts">Harbor + ORAS / ModelPack&lt;/text>
&lt;text x="373" y="92" text-anchor="middle" class="ts">blobs by sha256, immutable&lt;/text>
&lt;text x="373" y="112" text-anchor="middle" class="ts">quotas, retention, replication&lt;/text>
&lt;path class="ar" d="M478,81 L528,81"/>
&lt;rect class="bx" x="528" y="38" width="200" height="86" rx="5"/>
&lt;text x="628" y="58" text-anchor="middle" class="tl">Deployment plane&lt;/text>
&lt;text x="628" y="76" text-anchor="middle" class="ts">image volume / modelcar&lt;/text>
&lt;text x="628" y="92" text-anchor="middle" class="ts">mounted at /mnt/models&lt;/text>
&lt;text x="628" y="112" text-anchor="middle" class="ts">ephemeral or NVMe cache&lt;/text>
&lt;text x="40" y="162" class="tl">Hot path and bandwidth per leg (140 GB model)&lt;/text>
&lt;rect class="bx" x="40" y="178" width="150" height="52" rx="5"/>
&lt;text x="115" y="198" text-anchor="middle" class="tl">Registry (network)&lt;/text>
&lt;text x="115" y="216" text-anchor="middle" class="ts">10 Gb/s → approx. 112 s&lt;/text>
&lt;path class="ar" d="M190,204 L240,204"/>
&lt;rect class="bx" x="240" y="178" width="150" height="52" rx="5"/>
&lt;text x="315" y="198" text-anchor="middle" class="tl">Local NVMe cache&lt;/text>
&lt;text x="315" y="216" text-anchor="middle" class="ts">7 GB/s → approx. 20 s&lt;/text>
&lt;path class="ar" d="M390,204 L440,204"/>
&lt;rect class="bx" x="440" y="178" width="150" height="52" rx="5"/>
&lt;text x="515" y="198" text-anchor="middle" class="tl">PCIe Gen5 x16&lt;/text>
&lt;text x="515" y="216" text-anchor="middle" class="ts">approx. 50 GB/s → 3 s&lt;/text>
&lt;path class="ar" d="M590,204 L640,204"/>
&lt;rect class="bx" x="640" y="178" width="180" height="52" rx="5"/>
&lt;text x="730" y="198" text-anchor="middle" class="tl">HBM (H100 SXM)&lt;/text>
&lt;text x="730" y="216" text-anchor="middle" class="ts">TB/s: never the bottleneck&lt;/text>
&lt;text x="40" y="262" class="ts">The first leg dominates by one or two orders of magnitude: without a local cache, all the rest is noise.&lt;/text>
&lt;text x="40" y="280" class="ts">Load accelerators (streamer, tensorizer) attack legs 2-3; P2P and lazy loading attack leg 1.&lt;/text>
&lt;text x="40" y="298" class="ts">A mutable tag in the repository plane voids the guarantees of the other two planes.&lt;/text>
&lt;text x="40" y="316" class="ts">Signatures and attestations hang off the digest via subject + Referrers API (article 3/4).&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="image-volumes-the-change-of-the-year">Image volumes: the change of the year&lt;/h3>
&lt;p>The &lt;strong>image volume source&lt;/strong> (KEP-4639) mounts an OCI image or artefact directly as a read-only volume in a pod: no &lt;code>initContainer&lt;/code>, no copying, no PVC. Its track record: alpha in v1.31 (August 2024), beta in v1.33 (April 2025) with &lt;code>subPath&lt;/code> support, beta by default in v1.35 and GA in v1.36, released on 22 April 2026.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-modelo-oci&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">registro.interno/runtime/vllm:v0.11.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;--model&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;/mnt/models&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--served-model-name&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;llama-3.3-70b&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pesos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/mnt/models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readOnly&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">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="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">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pesos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">reference&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">registro.interno/modelos/llama-3.3-70b@sha256:9f2c...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pullPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">IfNotPresent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two details that change operations: the reference is by digest, so immutability stops depending on anyone&amp;rsquo;s discipline; and &lt;code>pullPolicy: IfNotPresent&lt;/code> makes the node&amp;rsquo;s runtime cache the blobs in its local store, so the second replica on that node starts without touching the network. It is the coordinated cache the anti-pattern was missing.&lt;/p>
&lt;h3 id="kserve-storageuri-and-modelcars">KServe: &lt;code>storageUri&lt;/code> and modelcars&lt;/h3>
&lt;p>In KServe there are two routes: the classic one, &lt;code>storageUri&lt;/code> with a &lt;em>storage initializer&lt;/em> that copies from S3, PVC, HTTP or GCS into an &lt;code>emptyDir&lt;/code>; and the &lt;strong>modelcar&lt;/strong>, with the &lt;code>oci://&lt;/code> scheme:&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">serving.kserve.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">InferenceService&lt;/span>&lt;span class="w">
&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">llama-70b-soberano&lt;/span>&lt;span class="w">
&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">predictor&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">modelFormat&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&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">huggingface&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">storageUri&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">oci://registro.interno/modelos/llama-3.3-70b:2026.07.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">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You have to understand the modelcar&amp;rsquo;s internal mechanism before depending on it: KServe sets &lt;code>shareProcessNamespace: true&lt;/code> on the pod, starts a sidecar container with the model image and creates a symbolic link from &lt;code>/mnt/models&lt;/code> to that process&amp;rsquo;s &lt;em>root filesystem&lt;/em> through &lt;code>/proc&lt;/code>. The runtime reads the weights without copying them, which is the saving being sought. Two warnings: it is not enabled by default (you have to turn on &lt;code>enableModelcar&lt;/code> in the &lt;code>inferenceservice-config&lt;/code> ConfigMap) and the &lt;code>latest&lt;/code> tag forces &lt;code>pullPolicy: Always&lt;/code>, cancelling out the cache. With image volumes now GA, the modelcar remains the route for clusters that are not yet on 1.36.&lt;/p>
&lt;h3 id="loading-fast-where-each-technique-attacks">Loading fast: where each technique attacks&lt;/h3>
&lt;p>You have to separate two problems that get confused all the time: getting the bytes to the node (network) and getting them into HBM (disk → CPU → PCIe → GPU).&lt;/p>
&lt;p>For the first, &lt;strong>Dragonfly&lt;/strong> is the mature piece: it graduated in CNCF on 14 January 2026, distributes weights at the scale of hundreds of terabytes to hundreds of nodes &amp;ldquo;in minutes&amp;rdquo;, cuts the bandwidth consumed at the origin by up to 90 %, and makes use of between 70 % and 80 % of each node&amp;rsquo;s bandwidth. Its v2.5.0 (30 June 2026) added direct download from model repositories (&lt;code>dfget hf://...&lt;/code>), P2P acceleration of Git LFS and a &lt;code>dragonfly-injector&lt;/code> that injects P2P capability via a webhook without rebuilding images. Its subproject &lt;strong>Nydus&lt;/strong> supplies the lazy-loading image format. There is a very common misplaced expectation here: for model weights lazy loading helps little, because an inference engine reads &lt;em>all&lt;/em> the weights almost immediately; the real benefit is for fat runtimes and for models where only part is read.&lt;/p>
&lt;p>For the second, the numbers published by the &lt;strong>NVIDIA Run:ai Model Streamer&lt;/strong> on Llama-3-8B (15 GB, safetensors, a node with one A10G) are the cleanest reference available:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Source&lt;/th>
&lt;th>Safetensors loader&lt;/th>
&lt;th>Run:ai Model Streamer&lt;/th>
&lt;th>Tensorizer&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>SSD gp3&lt;/td>
&lt;td>47.99 s&lt;/td>
&lt;td>14.34 s (concurrency 16)&lt;/td>
&lt;td>16.11 s (16 workers)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SSD io2&lt;/td>
&lt;td>47.0 s&lt;/td>
&lt;td>7.53 s (concurrency 8)&lt;/td>
&lt;td>10.36 s (8 workers)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>S3 object storage&lt;/td>
&lt;td>not supported&lt;/td>
&lt;td>4.88 s (concurrency 32)&lt;/td>
&lt;td>37.36 s (16 workers)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>And the total time until vLLM is ready to serve: 66.13 s with the standard loader over gp3 versus 35.08 s with the streamer. Two critical readings. First: these are the project&amp;rsquo;s own benchmarks, not an independent measurement, on a 15 GB model on a modest GPU; extrapolating linearly to 140 GB on 4×H100 is a leap nobody has published. Second: the gain comes from saturating the storage medium with concurrency. If the bottleneck is an NFS at 1 Gb/s, no loader fixes it. The detail of this leg is in &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">speeding up cold start with tensorizer&lt;/a> and in &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>.&lt;/p>
&lt;p>As the diagram shows, on a reference node of 4×H100 SXM 80 GB with NVLink each leg is between 5 and 10 times faster than the previous one. The conclusion is boring: the money is in having the model already on the node&amp;rsquo;s NVMe before the pod starts.&lt;/p>
&lt;h2 id="lora-adapters-when-the-artefact-weighs-megabytes">LoRA adapters: when the artefact weighs megabytes&lt;/h2>
&lt;p>Everything above assumes artefacts of tens or hundreds of gigabytes. A LoRA adapter breaks that assumption: for a 70B base, a rank-16 adapter over the attention projections weighs tens or a few hundred megabytes, three orders of magnitude less, and that changes the whole strategy.&lt;/p>
&lt;p>With artefacts like that, the local cache and P2P stop mattering: the download is instant at any reasonable bandwidth. What starts to matter is cardinality and rate of change. A multi-tenant platform like the one described in &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">multi-LoRA serving&lt;/a> can have hundreds of adapters, each with its owner and its access policy, over a handful of bases. The practical consequences:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The adapter is an OCI artefact in its own right&lt;/strong>, with its own repository and an immutable tag. But the registry then has thousands of small repositories instead of tens of large ones, which stresses metadata, listings and granular RBAC, not disk.&lt;/li>
&lt;li>&lt;strong>It must declare its base.&lt;/strong> An adapter with no immutable reference to the digest of the base model it was trained on is a time bomb: applied over another version, it degrades quality silently. It is the natural use case for &lt;code>subject&lt;/code>: the adapter &lt;em>refers&lt;/em> to the base, and the Referrers API answers &amp;ldquo;which adapters exist for this digest&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Loading is dynamic, not at start-up.&lt;/strong> vLLM allows hot loading of adapters with &lt;code>VLLM_ALLOW_RUNTIME_LORA_UPDATING&lt;/code> and the &lt;code>/v1/load_lora_adapter&lt;/code> endpoint, or declaratively with the &lt;em>LoRA resolver plugins&lt;/em> (&lt;code>VLLM_PLUGIN_LORA_RESOLVERS&lt;/code>), which resolve an adapter by name from a directory. The clean pattern on Kubernetes is to mount an image volume with the adapter and let the filesystem resolver discover it.&lt;/li>
&lt;li>&lt;strong>Versioning becomes the dominant problem.&lt;/strong> With 300 live adapters, knowing which adapter serves tenant X, over which base and trained with which dataset, only has an answer if the catalogue exists. Here a model registry does beat convention over tags.&lt;/li>
&lt;/ul>
&lt;h2 id="air-gap-and-sovereignty-promotion-without-losing-traceability">Air-gap and sovereignty: promotion without losing traceability&lt;/h2>
&lt;p>The strict sovereign case: a connected environment where you ingest and validate, and an isolated environment where you serve, with no network route between them. Promotion crosses a &lt;em>data diode&lt;/em> or a physical medium, and traceability has to survive the journey. It is the archive&amp;rsquo;s certified reproduction: the document, its record card and the seal certifying that the copy is faithful all travel together.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 1. In the connected environment: export the artefact with EVERYTHING that hangs off it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">oras backup &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --output /transfer/llama-3.3-70b-2026.07.1.tar &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --include-referrers &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> registro-dmz.interno/modelos/llama-3.3-70b:2026.07.1
&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. Verify the exact digest before crossing&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">oras manifest fetch --descriptor &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> registro-dmz.interno/modelos/llama-3.3-70b:2026.07.1 &amp;gt; /transfer/descriptor.json
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sha256sum /transfer/llama-3.3-70b-2026.07.1.tar &amp;gt; /transfer/SHA256SUMS
&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. Physical crossing or data diode&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. In the isolated environment: restore preserving digests and referrers&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">oras restore &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --input /transfer/llama-3.3-70b-2026.07.1.tar &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> registro-aislado.interno/modelos/llama-3.3-70b
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>--include-referrers&lt;/code> flag is what makes this worth anything: without it the weights cross, but the signature, the AIBOM and the attestations stay behind, and the isolated environment receives a model indistinguishable from one downloaded by hand. &lt;code>oras backup&lt;/code> is marked experimental in v1.3.0, which has to be accepted explicitly before turning it into a procedure; the consolidated alternative for pure copying is &lt;code>skopeo copy&lt;/code> with the &lt;code>oci-archive&lt;/code> format, at the cost of managing the referrers separately.&lt;/p>
&lt;p>What must travel attached to the artefact so that article 3/4 can verify it: the digest of the artefact and of its blobs; the provenance of the origin (the repository and the specific revision, not the model&amp;rsquo;s name, plus who ingested it and when); the identity of the process that packaged it (pipeline, commit, run), the raw material for an SLSA attestation; the cross-references to the evaluation dataset and to the base model if it is a derivative; and the promotion state with its approver, which in an isolated environment cannot be queried against the connected environment&amp;rsquo;s MLflow. All of it fits in OCI annotations and in artefacts referred to via &lt;code>subject&lt;/code>. The rule: the isolated environment must be able to verify without calling anyone; if verification needs an external query, it is not air-gap.&lt;/p>
&lt;h2 id="decision-map">Decision map&lt;/h2>
&lt;p>&lt;strong>An OCI registry and a convention are enough&lt;/strong> when you have fewer than twenty models or so, a single team decides what gets promoted, lineage back to training is not a regulatory requirement (typical if you consume third-party models) and you already operate Harbor. Here a model registry adds one more console to maintain and no answer that immutable tags and annotations were not already giving. It is the majority case in pure inference.&lt;/p>
&lt;p>&lt;strong>A model registry is needed&lt;/strong> if you train or fine-tune and need auditable lineage from model to dataset and to code, the &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning&lt;/a> cycle; if several teams compete to promote versions and an explicit approval flow is needed; if you have to answer an &lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO 42001&lt;/a> or &lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">EU AI Act&lt;/a> auditor about which version was in production on a given date; or if you manage tens of adapters with different owners.&lt;/p>
&lt;p>&lt;strong>The registry becomes valueless bureaucracy&lt;/strong> when the model is registered &lt;em>after&lt;/em> being deployed (the catalogue as an administrative act, not as a gate); when the promotion state is not connected to any technical control and an unapproved version can be deployed; or when a model registry and an OCI registry coexist with two truths and no digest joining them. The last one is the most common failure and the most expensive: two versioning systems that diverge in silence.&lt;/p>
&lt;h2 id="operational-traps">Operational traps&lt;/h2>
&lt;p>&lt;strong>Mutable tags and &lt;code>latest&lt;/code> in production.&lt;/strong> Beyond style, a mutable tag turns any audit into guesswork and lets two replicas of the same &lt;code>Deployment&lt;/code> serve different bytes. On top of that, in KServe &lt;code>latest&lt;/code> forces &lt;code>pullPolicy: Always&lt;/code> and cancels the node cache, so you pay in cold start what you lose in traceability. Rule: immutable tags by project policy and deployment by digest.&lt;/p>
&lt;p>&lt;strong>GC that deletes referenced layers.&lt;/strong> The archive&amp;rsquo;s weeding. The &lt;em>garbage collector&lt;/em> identifies blobs that no manifest references, and the window between &amp;ldquo;I have uploaded the blobs&amp;rdquo; and &amp;ldquo;I have uploaded the manifest&amp;rdquo; is exactly where a concurrent GC can take them away. In Harbor this has historically shown up in &lt;code>_uploads&lt;/code> and in discrepancies between the space the UI claims to have freed and the real figure. Mitigation: GC in a window with pushes blocked, and do not trust the first pass.&lt;/p>
&lt;p>&lt;strong>The registry&amp;rsquo;s disk and the cost of versioning.&lt;/strong> Brutal arithmetic: 140 GB per version with three live versions is 420 GB; twelve models like that, 5 TB; with six-version retention and two replicated sites, 20 TB. And it is expensive-class storage if the registry lives on replicated block. Deduplication helps little, because the weights change completely between versions. The retention policy has to be designed &lt;em>before&lt;/em> filling the registry, and models must be separated from runtime images by project, because their retentions have nothing to do with each other.&lt;/p>
&lt;p>&lt;strong>Replication that saturates the inter-site link.&lt;/strong> An event-driven rule on a model repository moves 140 GB every time someone pushes a version: on a shared 1 Gb/s link, almost twenty minutes flat out in the middle of the working day. Schedule it in a window, limit bandwidth, filter by label which versions get replicated and use P2P inside each site.&lt;/p>
&lt;p>&lt;strong>Putting the model inside the runtime image.&lt;/strong> Tempting because &amp;ldquo;that way there is only one artefact&amp;rdquo;. The result is a 145 GB image that has to be rebuilt and redistributed every time a runtime CVE is patched, with the security life cycle coupled to the model&amp;rsquo;s. Separated: a small, patchable runtime and a large, stable model.&lt;/p>
&lt;h2 id="for-an-inference-factory">For an inference factory&lt;/h2>
&lt;p>Three actionable things for anyone running an on-premise &lt;em>inference&lt;/em> factory, not a training one.&lt;/p>
&lt;p>&lt;strong>First: cut the internet dependency at start-up, this week.&lt;/strong> You do not need a platform project: an ingestion task that pulls each model once, pushes it to Harbor as an OCI artefact with an immutable tag and provenance annotations, plus switching the deployments to reference by digest, is enough. The &lt;code>HF_TOKEN&lt;/code> disappears from the pod and moves to the ingestion pipeline, which is where it makes sense and where it fits with &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">secrets hardening&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Second: decide consciously whether you need a catalogue.&lt;/strong> If you only consume third-party models and do not train, probably not: the OCI registry with a convention gives 90 % of the value at 10 % of the operational cost. If you train, fine-tune or manage per-tenant adapters, set up MLflow and store the OCI digest as a tag on each version, so that catalogue and repository cannot diverge.&lt;/p>
&lt;p>&lt;strong>Third: move the bottleneck to the right place.&lt;/strong> Before investing in load accelerators, measure the three legs. If the model arrives over the network on every start-up, the work is a local NVMe cache, image volumes with &lt;code>pullPolicy: IfNotPresent&lt;/code> and, with many nodes, P2P. Only when the model is already on the node&amp;rsquo;s disk does it make sense to wrestle with loader concurrency.&lt;/p>
&lt;p>So much for where the bytes come from and by what route they arrive. What remains is what we have taken for granted all along: why trust those bytes. A digest guarantees integrity, that nobody has changed them, but not provenance: it does not say who produced them, with what data or whether somebody with authority certified that they could be served. The &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-signature-provenance-aibom-why-you-trust-those-bytes/">third article in the series&lt;/a> builds that answer with Sigstore, SLSA, in-toto and AIBOM, hanging precisely off the &lt;code>subject&lt;/code> and the Referrers API that we have left ready here.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&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 control plane that consumes the &lt;code>storageUri&lt;/code> described here.&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/">Model chain of trust (3/4): signing, provenance and AIBOM&lt;/a> — what hangs off the digest via &lt;code>subject&lt;/code> and the Referrers API.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up cold start: model loading with tensorizer&lt;/a> — the disk-to-HBM leg in detail.&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: anatomy of the cold start&lt;/a> — where the time goes inside the node.&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 equivalent plane for datasets, which feeds the catalogue&amp;rsquo;s lineage.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">Catalogue of OSS LLMOps tools&lt;/a> — where each piece of this article fits in the complete stack.&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: fundamentals&lt;/a> — the case where the artefact weighs megabytes and the strategy changes.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Open Container Initiative, &lt;em>OCI Image and Distribution Specs v1.1 Releases&lt;/em> (13 March 2024) — &lt;a href="https://opencontainers.org/posts/blog/2024-03-13-image-and-distribution-1-1/">https://opencontainers.org/posts/blog/2024-03-13-image-and-distribution-1-1/&lt;/a>&lt;/li>
&lt;li>CNCF, &lt;em>Announcing ORAS v1.3.0: Elevating artifact and registry management workflows&lt;/em> (6 October 2025) — &lt;a href="https://www.cncf.io/blog/2025/10/06/announcing-oras-v1-3-0-elevating-artifact-and-registry-management-workflows/">https://www.cncf.io/blog/2025/10/06/announcing-oras-v1-3-0-elevating-artifact-and-registry-management-workflows/&lt;/a>&lt;/li>
&lt;li>CNCF, &lt;em>ORAS project page&lt;/em> (sandbox since 13 July 2021) — &lt;a href="https://www.cncf.io/projects/oras/">https://www.cncf.io/projects/oras/&lt;/a>&lt;/li>
&lt;li>ORAS, &lt;em>oras backup (experimental)&lt;/em> — &lt;a href="https://oras.land/docs/commands/oras_backup">https://oras.land/docs/commands/oras_backup&lt;/a>&lt;/li>
&lt;li>ModelPack, &lt;em>CNCF ModelPack Specification — model spec (artifactType and mediaTypes)&lt;/em> — &lt;a href="https://github.com/modelpack/model-spec">https://github.com/modelpack/model-spec&lt;/a>&lt;/li>
&lt;li>ModelPack, &lt;em>modctl — getting started (Modelfile and commands)&lt;/em> — &lt;a href="https://github.com/modelpack/modctl/blob/main/docs/getting-started.md">https://github.com/modelpack/modctl/blob/main/docs/getting-started.md&lt;/a>&lt;/li>
&lt;li>CNCF, &lt;em>Cloud Native Computing Foundation announces Harbor graduation&lt;/em> (23 June 2020) — &lt;a href="https://www.cncf.io/announcements/2020/06/23/cloud-native-computing-foundation-announces-harbor-graduation/">https://www.cncf.io/announcements/2020/06/23/cloud-native-computing-foundation-announces-harbor-graduation/&lt;/a>&lt;/li>
&lt;li>Harbor, &lt;em>Configure proxy cache&lt;/em> (supported registries) — &lt;a href="https://goharbor.io/docs/2.6.0/administration/configure-proxy-cache/">https://goharbor.io/docs/2.6.0/administration/configure-proxy-cache/&lt;/a>&lt;/li>
&lt;li>OneUptime, &lt;em>How to configure Harbor project quotas for storage limits&lt;/em> (9 February 2026) — &lt;a href="https://oneuptime.com/blog/post/2026-02-09-harbor-project-quotas-storage/view">https://oneuptime.com/blog/post/2026-02-09-harbor-project-quotas-storage/view&lt;/a>&lt;/li>
&lt;li>endoflife.date, &lt;em>Harbor releases&lt;/em> (2.15.2 on 2 July 2026; 2.15 on 20 March 2026) — &lt;a href="https://endoflife.date/harbor">https://endoflife.date/harbor&lt;/a>&lt;/li>
&lt;li>VMware Cloud Foundation Blog, &lt;em>Using Harbor as an AI Model Registry&lt;/em> (3 March 2026) — &lt;a href="https://blogs.vmware.com/cloud-foundation/2026/03/03/using-harbor-as-an-ai-model-registry/">https://blogs.vmware.com/cloud-foundation/2026/03/03/using-harbor-as-an-ai-model-registry/&lt;/a>&lt;/li>
&lt;li>MLflow, &lt;em>Model Registry workflows — stages deprecated since MLflow 2.9.0&lt;/em> — &lt;a href="https://mlflow.org/docs/latest/ml/model-registry/workflow/">https://mlflow.org/docs/latest/ml/model-registry/workflow/&lt;/a>&lt;/li>
&lt;li>MLflow, &lt;em>Release 3.13.0 highlights&lt;/em> — &lt;a href="https://mlflow.org/releases/3.13.0/">https://mlflow.org/releases/3.13.0/&lt;/a>&lt;/li>
&lt;li>Kubeflow, &lt;em>Hub (formerly Model Registry) — overview&lt;/em> — &lt;a href="https://www.kubeflow.org/docs/components/hub/overview/">https://www.kubeflow.org/docs/components/hub/overview/&lt;/a>&lt;/li>
&lt;li>kubernetes/enhancements, &lt;em>KEP-4639: OCI VolumeSource (alpha 1.31, beta 1.33/1.35, GA 1.36)&lt;/em> — &lt;a href="https://github.com/kubernetes/enhancements/blob/master/keps/sig-node/4639-oci-volume-source/README.md">https://github.com/kubernetes/enhancements/blob/master/keps/sig-node/4639-oci-volume-source/README.md&lt;/a>&lt;/li>
&lt;li>PerfectScale, &lt;em>Kubernetes v1.36 release: OCI artifact volume support reaches GA&lt;/em> (22 April 2026) — &lt;a href="https://www.perfectscale.io/blog/kubernetes-v1-36-release">https://www.perfectscale.io/blog/kubernetes-v1-36-release&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>OCI storage (modelcar)&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/model-serving/storage/providers/oci">https://kserve.github.io/website/docs/model-serving/storage/providers/oci&lt;/a>&lt;/li>
&lt;li>CNCF, &lt;em>CNCF announces Dragonfly&amp;rsquo;s graduation&lt;/em> (14 January 2026) — &lt;a href="https://www.cncf.io/announcements/2026/01/14/cloud-native-computing-foundation-announces-dragonflys-graduation/">https://www.cncf.io/announcements/2026/01/14/cloud-native-computing-foundation-announces-dragonflys-graduation/&lt;/a>&lt;/li>
&lt;li>CNCF, &lt;em>Dragonfly v2.5.0 is released&lt;/em> (30 June 2026) — &lt;a href="https://www.cncf.io/blog/2026/06/30/dragonfly-v2-5-0-is-released/">https://www.cncf.io/blog/2026/06/30/dragonfly-v2-5-0-is-released/&lt;/a>&lt;/li>
&lt;li>NVIDIA Run:ai, &lt;em>Model Streamer benchmarks&lt;/em> — &lt;a href="https://github.com/run-ai/runai-model-streamer/blob/master/docs/src/benchmarks.md">https://github.com/run-ai/runai-model-streamer/blob/master/docs/src/benchmarks.md&lt;/a>&lt;/li>
&lt;li>vLLM, &lt;em>LoRA adapters (dynamic loading and resolver plugins)&lt;/em> — &lt;a href="https://docs.vllm.ai/en/stable/features/lora/">https://docs.vllm.ai/en/stable/features/lora/&lt;/a>&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></channel></rss>