<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Fine-Tuning on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/fine-tuning/</link><description>Recent content in Fine-Tuning on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 09 Jun 2026 03:00:00 +0000</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/fine-tuning/index.xml" rel="self" type="application/rss+xml"/><item><title>QLoRA runbook: from dataset to adapter served in multi-LoRA (operational procedure)</title><link>https://blog.lo0.es/en/posts/qlora-runbook-dataset-to-served-adapter/</link><pubDate>Tue, 09 Jun 2026 03:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/qlora-runbook-dataset-to-served-adapter/</guid><description>&lt;blockquote>
&lt;p>This is the &lt;strong>operational companion&lt;/strong> to &lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and multi-LoRA at the limit on small models&lt;/a>. That post takes apart the &lt;em>why&lt;/em>, NF4, double quantisation, paged optimizers, the adapter maths; this is the &lt;em>how&lt;/em>, with commands you copy and paste. If you have not read the fundamentals one, read it first: here we assume you know what an adapter is, why the base lives in 4-bit and why the gradient only touches the adapter.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A reproducible procedure in five phases: &lt;strong>(1)&lt;/strong> pin the environment with fixed versions; &lt;strong>(2)&lt;/strong> prepare the dataset in chat format; &lt;strong>(3)&lt;/strong> train the QLoRA adapter with TRL + PEFT on an &lt;strong>RTX 4090 (24 GB, Ada Lovelace)&lt;/strong> using gradient checkpointing, gradient accumulation and &lt;code>paged_adamw_8bit&lt;/code>; &lt;strong>(4)&lt;/strong> validate and version the adapter as an artefact of &lt;strong>megabytes&lt;/strong>; &lt;strong>(5)&lt;/strong> serve it in &lt;strong>vLLM&lt;/strong> with &lt;code>--enable-lora&lt;/code>, loading it hot without restarting the server and resolving it from object storage. All on-premise, on consumer hardware, without a single data item leaving the perimeter. What follows are the exact commands and the memory budget that separates &amp;ldquo;it fits&amp;rdquo; from &amp;ldquo;OOM&amp;rdquo;.&lt;/p>
&lt;h2 id="the-end-to-end-flow">The end-to-end flow&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="QLoRA operational pipeline: dataset, training, adapter artefact, registry, vLLM serving">
&lt;defs>&lt;marker id="rm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;rect x="14" y="74" width="118" height="52" rx="7" fill="#eef2f6" stroke="currentColor" stroke-width="1.4"/>
&lt;text x="73" y="98" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">1 · Dataset&lt;/text>
&lt;text x="73" y="114" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#555">JSONL chat&lt;/text>
&lt;rect x="172" y="74" width="118" height="52" rx="7" fill="#fff4d6" stroke="#a48000" stroke-width="1.6"/>
&lt;text x="231" y="94" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#7a5e00">2 · Train&lt;/text>
&lt;text x="231" y="110" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">TRL+PEFT · 4090&lt;/text>
&lt;text x="231" y="122" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">NF4 · paged_adamw&lt;/text>
&lt;rect x="330" y="74" width="118" height="52" rx="7" fill="#fffbe9" stroke="#a48000" stroke-width="1.4"/>
&lt;text x="389" y="94" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#7a5e00">3 · Adapter&lt;/text>
&lt;text x="389" y="110" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">~17 MB&lt;/text>
&lt;text x="389" y="122" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">safetensors&lt;/text>
&lt;rect x="488" y="74" width="118" height="52" rx="7" fill="#e6d9f2" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="547" y="94" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#42208a">4 · Registry&lt;/text>
&lt;text x="547" y="110" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#42208a">MinIO / S3&lt;/text>
&lt;text x="547" y="122" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#42208a">versioned + sha256&lt;/text>
&lt;rect x="646" y="74" width="118" height="52" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.6"/>
&lt;text x="705" y="94" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#0d3a66">5 · Serve&lt;/text>
&lt;text x="705" y="110" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">vLLM --enable-lora&lt;/text>
&lt;text x="705" y="122" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">hot loading&lt;/text>
&lt;path d="M132,100 L170,100" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm)"/>
&lt;path d="M290,100 L328,100" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm)"/>
&lt;path d="M448,100 L486,100" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm)"/>
&lt;path d="M606,100 L644,100" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm)"/>
&lt;text x="389" y="36" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">Producer (4090) ───────────────▶ Consumer (4090 or cluster)&lt;/text>
&lt;path d="M231,150 L231,168 L547,168 L547,150" stroke="#999" stroke-width="1.2" fill="none" stroke-dasharray="4 3"/>
&lt;text x="389" y="185" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#555">the same machine can be producer and consumer&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="phase-0--environment-and-versions">Phase 0 — Environment and versions&lt;/h2>
&lt;p>QLoRA is sensitive to the versions of &lt;code>bitsandbytes&lt;/code>, &lt;code>transformers&lt;/code>, &lt;code>peft&lt;/code> and &lt;code>trl&lt;/code>: misaligned combinations give dequant errors or adapters that do not load in vLLM. Pin the environment and do not touch it mid-campaign. Reference versions as of June 2026 (check the specific ones in your index; the exact pin matters less than the coherence between them):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">python -m venv .venv &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nb">source&lt;/span> .venv/bin/activate
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install --upgrade pip
&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"># Training (producer)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install &lt;span class="s2">&amp;#34;torch&amp;gt;=2.4&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> &lt;span class="s2">&amp;#34;transformers&amp;gt;=4.50&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> &lt;span class="s2">&amp;#34;peft&amp;gt;=0.14&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> &lt;span class="s2">&amp;#34;trl&amp;gt;=0.15&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> &lt;span class="s2">&amp;#34;bitsandbytes&amp;gt;=0.45&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> &lt;span class="s2">&amp;#34;accelerate&amp;gt;=1.2&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> &lt;span class="s2">&amp;#34;datasets&amp;gt;=3.2&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Serving (consumer) — in its own environment/image&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install &lt;span class="s2">&amp;#34;vllm&amp;gt;=0.8&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What each piece does and why it is pinned:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Package&lt;/th>
&lt;th>Role in the flow&lt;/th>
&lt;th>Why the version matters&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>torch&lt;/code>&lt;/td>
&lt;td>tensor runtime and CUDA kernels&lt;/td>
&lt;td>the CUDA ABI has to match the driver and &lt;code>bitsandbytes&lt;/code>; a major jump breaks the 4-bit kernels.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>transformers&lt;/code>&lt;/td>
&lt;td>loads the base, the tokenizer and the &lt;code>chat_template&lt;/code>&lt;/td>
&lt;td>it has to &lt;strong>know the architecture&lt;/strong> of the SLM you use; a new model needs a version that supports it.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>peft&lt;/code>&lt;/td>
&lt;td>implements LoRA/QLoRA: injects the &lt;code>A,B&lt;/code> matrices and writes the &lt;code>adapter_config.json&lt;/code>&lt;/td>
&lt;td>that &lt;code>adapter_config.json&lt;/code> is what &lt;strong>vLLM reads&lt;/strong> when serving; old versions write fields the serving side does not understand.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>trl&lt;/code>&lt;/td>
&lt;td>the &lt;code>SFTTrainer&lt;/code>: the supervised training loop&lt;/td>
&lt;td>it integrates &lt;code>peft&lt;/code> natively; its API (&lt;code>SFTConfig&lt;/code>) changes between versions, hence the pin.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bitsandbytes&lt;/code>&lt;/td>
&lt;td>the NF4 quantisation and &lt;code>paged_adamw_8bit&lt;/code>&lt;/td>
&lt;td>&lt;strong>the most sensitive piece&lt;/strong>: a badly compiled binary gives corrupt dequant or hangs on the first step.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>accelerate&lt;/code>&lt;/td>
&lt;td>orchestrates device, mixed precision and &lt;code>device_map&lt;/code>&lt;/td>
&lt;td>the silent backend of almost everything; misaligning it with &lt;code>transformers&lt;/code> gives cryptic errors.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>datasets&lt;/code>&lt;/td>
&lt;td>loads the JSONL (and allows streaming if the corpus is large)&lt;/td>
&lt;td>not very sensitive; any recent 3.x will do.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm&lt;/code>&lt;/td>
&lt;td>the multi-LoRA serving&lt;/td>
&lt;td>&lt;strong>separate environment or image&lt;/strong>: do not mix its stack with the training &lt;code>bitsandbytes&lt;/code>.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The golden rule: &lt;strong>coherence between the four above&lt;/strong> (&lt;code>transformers&lt;/code>, &lt;code>peft&lt;/code>, &lt;code>trl&lt;/code>, &lt;code>bitsandbytes&lt;/code>) matters more than the exact number of each. Pin them when starting a campaign and do not move them until it closes.&lt;/p>
&lt;p>Check that the GPU and CUDA are healthy before starting; a badly compiled &lt;code>bitsandbytes&lt;/code> shows up late:&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">python -c &lt;span class="s2">&amp;#34;import torch, bitsandbytes; print(torch.cuda.get_device_name(0), torch.cuda.is_available())&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi --query-gpu&lt;span class="o">=&lt;/span>name,memory.total,driver_version --format&lt;span class="o">=&lt;/span>csv
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For 100 % sovereignty: download the base once from your internal Hugging Face mirror (or a MinIO with the weights) and export &lt;code>HF_HOME&lt;/code> to a local volume. Nothing in this flow needs to leave the perimeter.&lt;/p>
&lt;h2 id="phase-1--preparing-the-dataset">Phase 1 — Preparing the dataset&lt;/h2>
&lt;p>The canonical format for a conversational task is JSONL, one conversation per line, with the model&amp;rsquo;s chat template. Do not invent your own format: use the base tokenizer&amp;rsquo;s &lt;code>chat_template&lt;/code>, because any mismatch between how you train and how you serve degrades quality silently.&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-jsonl" data-lang="jsonl">{&amp;#34;messages&amp;#34;:[{&amp;#34;role&amp;#34;:&amp;#34;system&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;You are a network support assistant.&amp;#34;},{&amp;#34;role&amp;#34;:&amp;#34;user&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;The north wing AP does not come up after the outage.&amp;#34;},{&amp;#34;role&amp;#34;:&amp;#34;assistant&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;First confirm the PoE on the port...&amp;#34;}]}
{&amp;#34;messages&amp;#34;:[{&amp;#34;role&amp;#34;:&amp;#34;user&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;Generate the VLAN change for customer 42.&amp;#34;},{&amp;#34;role&amp;#34;:&amp;#34;assistant&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;interface GigabitEthernet0/3\n switchport access vlan 42...&amp;#34;}]}
&lt;/code>&lt;/pre>&lt;p>What each field is and why:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>What it is&lt;/th>
&lt;th>Operational note&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>messages&lt;/code>&lt;/td>
&lt;td>the complete conversation, a list of turns&lt;/td>
&lt;td>one conversation per JSONL line; it is what &lt;code>apply_chat_template&lt;/code> turns into tokens.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>role&lt;/code>&lt;/td>
&lt;td>who is speaking: &lt;code>system&lt;/code>, &lt;code>user&lt;/code>, &lt;code>assistant&lt;/code>&lt;/td>
&lt;td>the adapter learns to produce the &lt;code>assistant&lt;/code> turns; the &lt;code>user&lt;/code>/&lt;code>system&lt;/code> ones are context, not target.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>content&lt;/code>&lt;/td>
&lt;td>the text of the turn&lt;/td>
&lt;td>the &lt;code>system&lt;/code> one sets the persona/task; keep it &lt;strong>identical&lt;/strong> to the one you will use in production or the adapter drifts out of alignment.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Operational rules that save grief: watch the &lt;strong>ratio of examples&lt;/strong> (a well-curated narrow-task dataset of 2,000-20,000 examples performs better than 200,000 noisy ones), &lt;strong>deduplicate&lt;/strong>, and set aside 5-10 % as a validation split that does NOT go into training. Building the corpus from production signal is covered by &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop&lt;/a>.&lt;/p>
&lt;h2 id="phase-2--the-training-script">Phase 2 — The training script&lt;/h2>
&lt;p>A minimal, complete script with TRL + PEFT. It trains an r=8 adapter on an 8B SLM quantised to NF4. Each block has its rationale commented.&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"># train_qlora.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">torch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">datasets&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">load_dataset&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">transformers&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">AutoModelForCausalLM&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">BitsAndBytesConfig&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">peft&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">LoraConfig&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">trl&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">SFTConfig&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">SFTTrainer&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">BASE&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Qwen/Qwen3-8B&amp;#34;&lt;/span> &lt;span class="c1"># or whichever SLM you serve; ALWAYS use the same one in train and serve&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">OUT&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;adapters/soporte-redes-v1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 1) Base frozen and quantised to 4-bit NF4 with double quantisation&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">bnb&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">BitsAndBytesConfig&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">load_in_4bit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">bnb_4bit_quant_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;nf4&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># NormalFloat, quantile-optimal for Gaussian weights&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">bnb_4bit_use_double_quant&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># quantises the scale constants -&amp;gt; ~0.37 bits/param less&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">bnb_4bit_compute_dtype&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">torch&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">bfloat16&lt;/span> &lt;span class="c1"># the matmuls run in BF16 after dequant on the fly&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tok&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BASE&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">model&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoModelForCausalLM&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">BASE&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">quantization_config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">bnb&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">torch_dtype&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">torch&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">bfloat16&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">device_map&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 2) The adapter: low rank, attention projections only (aggressive). Raise target_modules if the eval asks for it.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">peft_cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">LoraConfig&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">8&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">lora_alpha&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">lora_dropout&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.05&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">bias&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;none&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">task_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;CAUSAL_LM&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">target_modules&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;q_proj&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;k_proj&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;v_proj&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;o_proj&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">ds&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">load_dataset&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;json&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">data_files&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;train&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;data/train.jsonl&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;eval&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;data/eval.jsonl&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 3) Training config designed to fit in 24 GB&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">SFTConfig&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">output_dir&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">OUT&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">per_device_train_batch_size&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># small real batch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">gradient_accumulation_steps&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># EFFECTIVE batch = 1*16 = 16, without paying its VRAM all at once&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">gradient_checkpointing&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># recomputes activations in backward: trades compute for memory&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">optim&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;paged_adamw_8bit&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># paged optimizer: the airbag against VRAM peaks&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">learning_rate&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">2e-4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">lr_scheduler_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;cosine&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">warmup_ratio&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.03&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">num_train_epochs&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">bf16&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">max_length&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">2048&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># bounds the sequence: activations scale with it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">logging_steps&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">eval_strategy&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;steps&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">eval_steps&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">save_steps&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">report_to&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;none&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">trainer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">SFTTrainer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">args&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">peft_config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">peft_cfg&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">train_dataset&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ds&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;train&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">eval_dataset&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ds&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;eval&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">processing_class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">tok&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">trainer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">trainer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">save_model&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">OUT&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># saves ONLY the adapter (MB), not the base&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="bitsandbytesconfig--how-the-base-is-quantised">&lt;code>BitsAndBytesConfig&lt;/code> — how the base is quantised&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Option&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>Why this value / when to change it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>load_in_4bit=True&lt;/code>&lt;/td>
&lt;td>loads the base weights in 4-bit&lt;/td>
&lt;td>it is the foundation of QLoRA: without it the 8B does not even fit to train.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bnb_4bit_quant_type=&amp;quot;nf4&amp;quot;&lt;/code>&lt;/td>
&lt;td>uses the NF4 format (quantile-optimal for Gaussian weights)&lt;/td>
&lt;td>&lt;code>&amp;quot;fp4&amp;quot;&lt;/code> exists, but NF4 performs better on transformer weights; leave NF4.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bnb_4bit_use_double_quant=True&lt;/code>&lt;/td>
&lt;td>quantises the scale constants themselves&lt;/td>
&lt;td>saves ~0.37 bits/param (hundreds of MB on an 8B); the margin separating &amp;ldquo;it fits&amp;rdquo; from &amp;ldquo;OOM&amp;rdquo;. Leave it at &lt;code>True&lt;/code>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bnb_4bit_compute_dtype=torch.bfloat16&lt;/code>&lt;/td>
&lt;td>matmul precision after undoing the quantisation on the fly&lt;/td>
&lt;td>BF16 on Ada/Hopper (4090, H100); use &lt;code>float16&lt;/code> only on GPUs without BF16.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="loraconfig--the-shape-of-the-adapter">&lt;code>LoraConfig&lt;/code> — the shape of the adapter&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Option&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>Why this value / when to change it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>r=8&lt;/code>&lt;/td>
&lt;td>adapter rank: its correction capacity&lt;/td>
&lt;td>4-8 for a narrow task (aggressive); raise it to 16-64 only if the eval shows underfitting.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>lora_alpha=16&lt;/code>&lt;/td>
&lt;td>scale factor of the delta (effective &lt;code>α/r&lt;/code>)&lt;/td>
&lt;td>common convention &lt;code>α=2r&lt;/code>; it modulates how much the adapter &amp;ldquo;weighs&amp;rdquo; on the base.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>lora_dropout=0.05&lt;/code>&lt;/td>
&lt;td>regularisation on the adapter&lt;/td>
&lt;td>0.05-0.1 with small datasets (avoids overfit); 0 if the corpus is large.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bias=&amp;quot;none&amp;quot;&lt;/code>&lt;/td>
&lt;td>does not train the bias terms&lt;/td>
&lt;td>&lt;code>&amp;quot;none&amp;quot;&lt;/code> is the standard; &lt;code>&amp;quot;all&amp;quot;&lt;/code>/&lt;code>&amp;quot;lora_only&amp;quot;&lt;/code> rarely help and cost params.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>task_type=&amp;quot;CAUSAL_LM&amp;quot;&lt;/code>&lt;/td>
&lt;td>objective/head type&lt;/td>
&lt;td>fixed for a generative LLM.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>target_modules=[q,k,v,o]&lt;/code>&lt;/td>
&lt;td>which matrices get an adapter&lt;/td>
&lt;td>attention only = cheap and aggressive; add &lt;code>gate_proj&lt;/code>/&lt;code>up_proj&lt;/code>/&lt;code>down_proj&lt;/code> (MLP) if the task demands rewriting more behaviour and the eval asks for it.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="sftconfig--the-memory-budget-and-the-loop">&lt;code>SFTConfig&lt;/code> — the memory budget and the loop&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Option&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>Why this value / when to change it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>per_device_train_batch_size=1&lt;/code>&lt;/td>
&lt;td>microbatch per GPU&lt;/td>
&lt;td>1 on 24 GB; the real batch is built by &lt;code>gradient_accumulation_steps&lt;/code>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gradient_accumulation_steps=16&lt;/code>&lt;/td>
&lt;td>accumulates 16 microbatches before updating&lt;/td>
&lt;td>&lt;strong>effective&lt;/strong> batch = 1×16 = 16 without paying its VRAM all at once; raise it if you shorten the sequence and want a bigger effective batch.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gradient_checkpointing=True&lt;/code>&lt;/td>
&lt;td>recomputes activations in the backward pass instead of storing them&lt;/td>
&lt;td>essential on a 4090: ~20-30 % slower in exchange for much less VRAM.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>optim=&amp;quot;paged_adamw_8bit&amp;quot;&lt;/code>&lt;/td>
&lt;td>Adam optimizer in 8-bit + states pageable to RAM&lt;/td>
&lt;td>less state VRAM &lt;strong>and&lt;/strong> the airbag that avoids the OOM at the peaks.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>learning_rate=2e-4&lt;/code>&lt;/td>
&lt;td>learning rate of the adapter&lt;/td>
&lt;td>1e-4-3e-4 is the typical QLoRA range; adapters tolerate a higher LR than a full fine-tune.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>lr_scheduler_type=&amp;quot;cosine&amp;quot;&lt;/code>&lt;/td>
&lt;td>LR decay curve&lt;/td>
&lt;td>&lt;code>cosine&lt;/code> or &lt;code>linear&lt;/code>; cosine usually gives a smooth drop at the end.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>warmup_ratio=0.03&lt;/code>&lt;/td>
&lt;td>warms the LR over the first 3 % of steps&lt;/td>
&lt;td>avoids the instability of the first steps.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>num_train_epochs=3&lt;/code>&lt;/td>
&lt;td>complete passes over the dataset&lt;/td>
&lt;td>1-3; watch the &lt;em>eval loss&lt;/em> so as not to overfit.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bf16=True&lt;/code>&lt;/td>
&lt;td>compute and adapter precision&lt;/td>
&lt;td>BF16 on Ada/Hopper; &lt;code>fp16=True&lt;/code> if your GPU has no BF16.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>max_length=2048&lt;/code>&lt;/td>
&lt;td>maximum sequence length&lt;/td>
&lt;td>&lt;strong>the #1 VRAM lever&lt;/strong> for activations: shorten it first if there is an OOM.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>eval_strategy&lt;/code>/&lt;code>eval_steps&lt;/code>/&lt;code>save_steps&lt;/code>&lt;/td>
&lt;td>validation and checkpoint cadence&lt;/td>
&lt;td>tune them to the dataset size; evaluating often costs time.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The four pieces that make it fit on a 4090 are: &lt;code>per_device_train_batch_size=1&lt;/code> + &lt;code>gradient_accumulation_steps&lt;/code> (a large effective batch without its memory cost all at once), &lt;code>gradient_checkpointing=True&lt;/code> (recomputing activations instead of storing them) and &lt;code>optim=&amp;quot;paged_adamw_8bit&amp;quot;&lt;/code> (paging states to RAM at the peaks). Remove any of the three with long sequences and you will see the OOM.&lt;/p>
&lt;p>A declarative alternative with &lt;strong>Axolotl&lt;/strong> if you prefer YAML over Python (same result):&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">base_model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Qwen/Qwen3-8B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">load_in_4bit&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">adapter&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qlora&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">lora_r&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">lora_alpha&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">lora_target_modules&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">q_proj, k_proj, v_proj, o_proj]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">sequence_len&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2048&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">micro_batch_size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">gradient_accumulation_steps&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">gradient_checkpointing&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">optimizer&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">paged_adamw_8bit&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">learning_rate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0002&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">num_epochs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">bf16&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">datasets&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">data/train.jsonl&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat_template&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="phase-3--launch-and-monitor">Phase 3 — Launch and monitor&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Simple launch on one GPU&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python train_qlora.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># In another terminal: watch VRAM. If it gets close to the ceiling, lower max_length or raise grad accumulation.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">watch -n &lt;span class="m">2&lt;/span> nvidia-smi --query-gpu&lt;span class="o">=&lt;/span>memory.used,memory.total,utilization.gpu --format&lt;span class="o">=&lt;/span>csv
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Approximate VRAM budget when training the 8B on the 4090, and what to touch when it gets tight:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th style="text-align:right">Approx. VRAM&lt;/th>
&lt;th>Lever if there is an OOM&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>8B NF4 base (frozen)&lt;/td>
&lt;td style="text-align:right">~4.0 GB&lt;/td>
&lt;td>— (fixed)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Adapter + grad + Adam states&lt;/td>
&lt;td style="text-align:right">~0.3-0.7 GB&lt;/td>
&lt;td>lower &lt;code>r&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Activations (batch × sequence)&lt;/td>
&lt;td style="text-align:right">~6-14 GB&lt;/td>
&lt;td>lower &lt;code>max_length&lt;/code>, &lt;code>batch_size&lt;/code>; raise &lt;code>grad_accum&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dequant buffers / workspace&lt;/td>
&lt;td style="text-align:right">~1-2 GB&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Table of quick OOM remedies, in order of cost: &lt;strong>(1)&lt;/strong> lower &lt;code>max_length&lt;/code>; &lt;strong>(2)&lt;/strong> confirm &lt;code>gradient_checkpointing=True&lt;/code>; &lt;strong>(3)&lt;/strong> raise &lt;code>gradient_accumulation_steps&lt;/code> and lower &lt;code>per_device_train_batch_size&lt;/code> to 1; &lt;strong>(4)&lt;/strong> use &lt;code>paged_adamw_8bit&lt;/code> (already in the script); &lt;strong>(5)&lt;/strong> as a last resort lower &lt;code>r&lt;/code>. If after all that it still does not fit, the sequence or the model is too large for 24 GB: either you bound it, or you move up the hardware.&lt;/p>
&lt;h2 id="phase-4--validating-the-adapter">Phase 4 — Validating the adapter&lt;/h2>
&lt;p>Never promote an adapter on the strength of the &lt;em>training loss&lt;/em>. Measure against the validation split you set aside and against a handful of real prompts.&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"># quick_eval.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">torch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">transformers&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">AutoModelForCausalLM&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">BitsAndBytesConfig&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">peft&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">PeftModel&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">bnb&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">BitsAndBytesConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">load_in_4bit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">bnb_4bit_quant_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;nf4&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">bnb_4bit_use_double_quant&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">bnb_4bit_compute_dtype&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">torch&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">bfloat16&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tok&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Qwen/Qwen3-8B&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">base&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoModelForCausalLM&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Qwen/Qwen3-8B&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">quantization_config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">bnb&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">device_map&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">model&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">PeftModel&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">base&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;adapters/soporte-redes-v1&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># base + adapter&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">msgs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;The north wing AP does not come up after the outage.&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">ids&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">tok&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">apply_chat_template&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">msgs&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">add_generation_prompt&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">return_tensors&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;pt&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">to&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tok&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">generate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ids&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">max_new_tokens&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">256&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">skip_special_tokens&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For a serious verdict, run the adapter through your eval suite (the layer described by &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">LLM evals&lt;/a>) and compare against the base &lt;strong>without&lt;/strong> an adapter and against the previous version of the adapter. Promote only if it wins on the task metric without regressing on safety/format.&lt;/p>
&lt;h2 id="phase-5--versioning-the-adapter-as-an-artefact">Phase 5 — Versioning the adapter as an artefact&lt;/h2>
&lt;p>The adapter is a pair of files of a few MB (&lt;code>adapter_model.safetensors&lt;/code> + &lt;code>adapter_config.json&lt;/code>). Treat it as a versioned, signed, traceable artefact, not as a loose file.&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"># Reproducible checksum + upload to internal object storage (MinIO/S3)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sha256sum adapters/soporte-redes-v1/adapter_model.safetensors &amp;gt; adapters/soporte-redes-v1/SHA256
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">aws --endpoint-url https://minio.interno s3 cp &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> adapters/soporte-redes-v1/ s3://adapters/soporte-redes/v1/ --recursive
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A convention that works: &lt;code>s3://adapters/&amp;lt;task-or-customer&amp;gt;/&amp;lt;version&amp;gt;/&lt;/code>. Immutable per version, with its &lt;code>SHA256&lt;/code>. Deleting a customer is deleting a prefix of a few MB, not retraining anything. Versioning 500 adapters costs what versioning 500 heavy configuration files costs.&lt;/p>
&lt;h2 id="phase-6--serving-in-multi-lora-with-vllm">Phase 6 — Serving in multi-LoRA with vLLM&lt;/h2>
&lt;p>The consumer loads &lt;strong>one&lt;/strong> shared base and applies the adapter delta per request. Startup with static adapters declared:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">VLLM_ALLOW_RUNTIME_LORA_UPDATING&lt;/span>&lt;span class="o">=&lt;/span>True &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span>vllm serve Qwen/Qwen3-8B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-lora &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-loras &lt;span class="m">8&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># max nº of DIFFERENT adapters per batch (not the total loadable)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --max-lora-rank &lt;span class="m">8&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># = the maximum rank of your adapters; do not inflate it (wastes memory)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --max-cpu-loras &lt;span class="m">64&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># adapters cached in RAM for fast swap to VRAM&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --lora-modules soporte-redes&lt;span class="o">=&lt;/span>/srv/adapters/soporte-redes/v1
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Each flag, what it controls and how to size it:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Flag / variable&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>How to size it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>--enable-lora&lt;/code>&lt;/td>
&lt;td>turns on adapter support&lt;/td>
&lt;td>mandatory; without it, vLLM ignores any &lt;code>model&lt;/code> that is an adapter.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--max-loras 8&lt;/code>&lt;/td>
&lt;td>nº of &lt;strong>different adapters in a single batch&lt;/strong>&lt;/td>
&lt;td>more adapters per batch makes the SGMV kernels more expensive; 8-32 is reasonable. It is not the total loadable.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--max-lora-rank 8&lt;/code>&lt;/td>
&lt;td>maximum rank the server reserves&lt;/td>
&lt;td>set it &lt;strong>equal to the real rank&lt;/strong> of your adapters (8 here); inflating it wastes VRAM and performance.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--max-cpu-loras 64&lt;/code>&lt;/td>
&lt;td>adapters cached in RAM ready to page to VRAM&lt;/td>
&lt;td>≥ nº of active adapters; it is the &amp;ldquo;bench&amp;rdquo; from which fast swapping happens.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--lora-modules name=path&lt;/code>&lt;/td>
&lt;td>declares &lt;strong>static&lt;/strong> adapters at startup&lt;/td>
&lt;td>useful for the fixed ones; leave it out if everything goes through dynamic loading/Resolver.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>VLLM_ALLOW_RUNTIME_LORA_UPDATING=True&lt;/code>&lt;/td>
&lt;td>enables the hot load/unload endpoints&lt;/td>
&lt;td>essential for &lt;code>/v1/load_lora_adapter&lt;/code>; without it, the server is static.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;code>--max-loras&lt;/code> limits the different adapters &lt;strong>per batch&lt;/strong>, not how many you can have loaded; the bulk lives on CPU (&lt;code>--max-cpu-loras&lt;/code>) and is paged to VRAM on demand. Set &lt;code>--max-lora-rank&lt;/code> to the real rank (8 here): inflating it wastes memory and performance. Requests pick the adapter through the &lt;code>model&lt;/code> field:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl http://localhost:8000/v1/chat/completions -H &lt;span class="s2">&amp;#34;Content-Type: application/json&amp;#34;&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;model&amp;#34;: &amp;#34;soporte-redes&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;messages&amp;#34;: [{&amp;#34;role&amp;#34;:&amp;#34;user&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;The north wing AP does not come up after the outage.&amp;#34;}]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># model:&amp;#34;Qwen/Qwen3-8B&amp;#34; (no adapter) uses the bare base on the same server&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Hot loading&lt;/strong> of a new adapter without restarting (thanks to &lt;code>VLLM_ALLOW_RUNTIME_LORA_UPDATING=True&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -X POST http://localhost:8000/v1/load_lora_adapter -H &lt;span class="s2">&amp;#34;Content-Type: application/json&amp;#34;&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;lora_name&amp;#34;: &amp;#34;cliente-42&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;lora_path&amp;#34;: &amp;#34;/srv/adapters/cliente-42/v3&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># and to free VRAM/CPU when a customer goes idle:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -X POST http://localhost:8000/v1/unload_lora_adapter -H &lt;span class="s2">&amp;#34;Content-Type: application/json&amp;#34;&lt;/span> -d &lt;span class="s1">&amp;#39;{&amp;#34;lora_name&amp;#34;:&amp;#34;cliente-42&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For multi-tenant at scale, avoid declaring hundreds of adapters by hand: the &lt;strong>LoRAResolver&lt;/strong> resolves and loads the adapter from local storage or S3 the first time an unknown &lt;code>model&lt;/code> arrives, so the server stays lean and the adapters are pulled lazily from your MinIO. The internals of &lt;em>how&lt;/em> thousands of concurrent adapters are batched (SGMV kernels, unified paging, the heterogeneous gather/scatter) are in &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>; this runbook only switches them on. To squeeze the base&amp;rsquo;s decode throughput on a 4090, combine this with &lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a>.&lt;/p>
&lt;h2 id="serving-multi-adapter-vs-merging-per-task">Serving multi-adapter vs merging per task&lt;/h2>
&lt;p>Two deployment architectures, and the procedure changes:&lt;/p>
&lt;p>&lt;strong>Serving multi-LoRA (the above).&lt;/strong> A shared base + N hot adapters. It is the default sovereign pattern: minimum footprint, per-customer isolation, hot-swap. Use standard QLoRA and merge nothing.&lt;/p>
&lt;p>&lt;strong>Merging per task.&lt;/strong> If you want a single quantised-and-adapted artefact per task (no adapter at runtime), do not merge a standard QLoRA adapter into the 4-bit base: the merge reintroduces precision that NF4 does not represent and on requantising you lose part of what was learnt. For that case, train with &lt;strong>QA-LoRA&lt;/strong> (quantization-aware), which merges cleanly onto a quantised base. It is an architecture decision, not a quality one; the conceptual detail is in the &lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">fundamentals post&lt;/a>.&lt;/p>
&lt;h2 id="checklist-of-operational-gotchas">Checklist of operational gotchas&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Coherent chat template&lt;/strong> between training and serving. The most common and most silent mismatch: you train with one &lt;code>chat_template&lt;/code> and serve with another. Use the base&amp;rsquo;s on both sides.&lt;/li>
&lt;li>&lt;strong>Exactly the same base&lt;/strong> (revision included) in &lt;code>train&lt;/code> and &lt;code>serve&lt;/code>. An adapter trained on &lt;code>Qwen3-8B&lt;/code> is not valid on another revision of the model.&lt;/li>
&lt;li>&lt;strong>&lt;code>--max-lora-rank&lt;/code> ≥ the rank of ALL the adapters&lt;/strong> served together, but no more: inflating it wastes VRAM.&lt;/li>
&lt;li>&lt;strong>KV budget vs &lt;code>--max-loras&lt;/code>.&lt;/strong> The bottleneck in serving is not the adapters (MB), it is the KV cache and concurrency; see &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">Inverted roofline&lt;/a> for the SLM regime.&lt;/li>
&lt;li>&lt;strong>&lt;code>r&lt;/code> too low&lt;/strong> = underfitting if the task demands rewriting a lot of behaviour. Raise &lt;code>r&lt;/code> only if the eval asks for it.&lt;/li>
&lt;li>&lt;strong>Do not promote on training loss.&lt;/strong> Validate against the reserved split + real prompts + a safety regression.&lt;/li>
&lt;li>&lt;strong>Version and make immutable&lt;/strong> every adapter with its &lt;code>SHA256&lt;/code>; never overwrite a served version.&lt;/li>
&lt;/ul>
&lt;h2 id="applied-to-on-premise-infrastructure">Applied to on-premise infrastructure&lt;/h2>
&lt;p>On an &lt;strong>RTX 4090 (24 GB)&lt;/strong> the same machine is producer and consumer: you train a customer&amp;rsquo;s adapter in hours and serve it on the same server on top of the shared base. It is the canonical case for multi-tenant demos and platform prototypes.&lt;/p>
&lt;p>On a &lt;strong>generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/strong> QLoRA stops being necessary in order to &lt;em>fit&lt;/em>, but it serves to &lt;strong>parallelise production&lt;/strong> (several adapter jobs at once) and to keep the quantised format consistent between training and serious serving of hundreds of concurrent adapters. The base can run in native FP8; the mechanics of the runbook do not change, only the scale.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and multi-LoRA at the limit on small models&lt;/a> — the fundamentals post: the why of NF4, double quantisation, paged optimizers and the adapter maths. This runbook is its executable face.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a> — the consumer internals we only switch on here: SGMV, unified paging, heterogeneous batching of thousands of adapters.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — how to squeeze the decode throughput of the base you serve the adapters on top of, on a 4090.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the feedback → dataset → adapter loop&lt;/a> — where the Phase 1 dataset comes from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">LLM evals: the layer after tracing&lt;/a> — how to validate the Phase 4 adapter with judgement, not with the training loss.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">Inverted roofline on small models&lt;/a> — the performance regime that explains why the serving bottleneck is the KV cache, not the adapters.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">Aggressive quantisation: from 4-bit to ternary&lt;/a> — what happens to the quantised base below NF4 underneath the adapter.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Dettmers, T., Pagnoni, A., Holtzman, A., Zettlemoyer, L. &lt;em>QLoRA: Efficient Finetuning of Quantized LLMs&lt;/em>. NeurIPS 2023. &lt;a href="https://arxiv.org/abs/2305.14314">https://arxiv.org/abs/2305.14314&lt;/a>&lt;/li>
&lt;li>Hugging Face TRL — PEFT integration (SFTTrainer + QLoRA): &lt;a href="https://huggingface.co/docs/trl/peft_integration">https://huggingface.co/docs/trl/peft_integration&lt;/a>&lt;/li>
&lt;li>Hugging Face PEFT: &lt;a href="https://github.com/huggingface/peft">https://github.com/huggingface/peft&lt;/a>&lt;/li>
&lt;li>bitsandbytes: &lt;a href="https://github.com/bitsandbytes-foundation/bitsandbytes">https://github.com/bitsandbytes-foundation/bitsandbytes&lt;/a>&lt;/li>
&lt;li>vLLM — LoRA Adapters (serving, dynamic loading, LoRAResolver): &lt;a href="https://docs.vllm.ai/en/stable/features/lora/">https://docs.vllm.ai/en/stable/features/lora/&lt;/a>&lt;/li>
&lt;li>Axolotl: &lt;a href="https://github.com/axolotl-ai-cloud/axolotl">https://github.com/axolotl-ai-cloud/axolotl&lt;/a>&lt;/li>
&lt;li>Xu, Y. et al. &lt;em>QA-LoRA: Quantization-Aware Low-Rank Adaptation&lt;/em>. ICLR 2024. &lt;a href="https://arxiv.org/abs/2309.14717">https://arxiv.org/abs/2309.14717&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>QLoRA and multi-LoRA at the limit on small models</title><link>https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/</link><pubDate>Tue, 09 Jun 2026 02:30:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/</guid><description>&lt;blockquote>
&lt;p>This post is the training-side companion to &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>. That one takes apart the &lt;strong>consumer&lt;/strong>, how hundreds of concurrent adapters are served with SGMV kernels and unified paging; this one takes apart the &lt;strong>producer&lt;/strong>, how an adapter is trained on top of a quantised base on a single GPU, and why the pattern &amp;ldquo;one frozen base SLM + N low-rank adapters&amp;rdquo; is the natural fit for small models. We do not repeat the serving internals here; we assume you have read them.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>QLoRA&lt;/strong> (Dettmers et al., NeurIPS 2023) solves a specific problem: fine-tuning a model without having the VRAM to hold its weights in BF16, its gradients and the optimiser states. The idea has three pieces. One: freeze the base and quantise it to 4-bit with a new format, &lt;strong>NF4&lt;/strong> (NormalFloat 4-bit), quantile-optimal for weights distributed almost like a Gaussian. Two: do not train the base, not a single one of its weights moves, but rather a pair of small LoRA matrices in BF16 plugged in parallel; the gradient flows only through that adapter. Three: two memory tricks, &lt;em>double quantisation&lt;/em> (quantising the quantisation constants themselves) and &lt;em>paged optimizers&lt;/em> (optimiser states that page out to RAM when VRAM gets tight). The measurable operational result: a 3-8B SLM is fine-tuned on an &lt;strong>RTX 4090 (24 GB, Ada Lovelace)&lt;/strong>, not on a cluster. And because the output of training is an adapter measured in &lt;strong>megabytes, not gigabytes&lt;/strong>, the pattern that emerges is a single frozen base SLM in 4-bit plus N adapters, one per customer, domain or task, served on top of the shared base with the stack we already covered in multi-LoRA serving. Per-customer isolation, minimum footprint, sovereign deployment.&lt;/p>
&lt;h2 id="the-analogy-the-frozen-guitar-and-the-swappable-pedalboard">The analogy: the frozen guitar and the swappable pedalboard&lt;/h2>
&lt;p>Think of a session guitarist recording for very different clients: a jazz record, a corporate jingle, a metal track. He has &lt;strong>one single guitar&lt;/strong>, his trusted instrument, set up, with a base tone he knows by heart. What he does &lt;strong>not&lt;/strong> do is buy a new guitar for every song. What he does is keep a &lt;strong>pedalboard&lt;/strong>: a distortion pedal, a chorus, a delay. For each track he plugs in the pedal that fits, and the same guitar sounds completely different.&lt;/p>
&lt;p>The mapping is exact:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The guitar&lt;/strong> = the base SLM. A single copy, set up at the factory, frozen. In QLoRA it is also &lt;em>kept in a compressed case&lt;/em>: quantised to 4-bit. You do not touch it: you do not change its pickups or adjust the neck. It weighs what it weighs and there it stays.&lt;/li>
&lt;li>&lt;strong>Each pedal&lt;/strong> = a LoRA adapter. Small, cheap, specific to one sound. You train it for a task and put it in a drawer.&lt;/li>
&lt;li>&lt;strong>Training QLoRA&lt;/strong> = designing a new pedal by listening to the (frozen) guitar through it, adjusting only the pedal&amp;rsquo;s pots until it sounds the way you want. The guitar&amp;rsquo;s base tone is not modified; you learn the &lt;strong>correction&lt;/strong> the pedal applies on top.&lt;/li>
&lt;li>&lt;strong>Serving multi-LoRA&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>) = having the whole pedalboard set up on stage and choosing the right pedal &lt;strong>per note&lt;/strong>, per request. The guitar is the same; what changes between requests is which pedal is active.&lt;/li>
&lt;/ul>
&lt;p>The analogy holds down to the detail that confuses people most: &lt;strong>the training gradient only &amp;ldquo;touches&amp;rdquo; the pedal&lt;/strong>. The guitar is frozen in its compressed case; learning does not move it. That is what lets the base live in 4-bit throughout the fine-tuning without the quantisation getting in the way: no gradient is ever computed for it.&lt;/p>
&lt;h2 id="the-bare-mechanism-lora-and-why-you-can-train-on-a-4-bit-base">The bare mechanism: LoRA, and why you can train on a 4-bit base&lt;/h2>
&lt;p>A minimal reminder of LoRA (Hu et al., ICLR 2022). An adapter modifies a matrix &lt;code>W&lt;/code> of the base by adding a low-rank product to it:&lt;/p>
$$W' = W + B A, \qquad A \in \mathbb{R}^{r \times d}, \quad B \in \mathbb{R}^{d \times r}$$
&lt;p>with &lt;code>r&lt;/code> the &lt;strong>rank&lt;/strong>, much smaller than &lt;code>d&lt;/code>. In the forward pass &lt;code>BA&lt;/code> is not materialised; what is computed is:&lt;/p>
$$y = W x + B(A x)$$
&lt;p>The base compute (&lt;code>Wx&lt;/code>) happens just the same; the adapter adds two cheap matmuls. The key to QLoRA is who receives a gradient. The base &lt;code>W&lt;/code> is frozen: &lt;code>∂L/∂W&lt;/code> is neither computed nor stored. Only &lt;code>A&lt;/code> and &lt;code>B&lt;/code> are trainable. That is why &lt;code>W&lt;/code> can live quantised to 4-bit without trouble: in the forward pass the quantisation is &lt;em>undone&lt;/em> on the fly to compute &lt;code>Wx&lt;/code> (dequant → matmul in BF16), but since &lt;code>W&lt;/code> is never updated it does not need the precision of a trainable weight. The adapter &lt;code>A, B&lt;/code> is in BF16, and it is the only path the gradient flows through.&lt;/p>
&lt;p>This is what breaks the memory wall. In a full fine-tuning you need, per weight: the weight (2 bytes BF16), its gradient (2 bytes), and the two Adam states (momentum and variance, typically 4+4 bytes in FP32), on the order of &lt;strong>12-16 bytes per trainable parameter&lt;/strong>. With QLoRA, the base weights take &lt;strong>0.5 bytes&lt;/strong> (4-bit) and have no gradient and no optimiser states. Only the few million adapter parameters pay the 16-byte cost. For an 8B, that is the difference between ~130 GB and fitting in 24 GB.&lt;/p>
&lt;h3 id="nf4-why-a-new-format-instead-of-int4">NF4: why a new format instead of INT4&lt;/h3>
&lt;p>QLoRA does not use linear INT4 for the base, but &lt;strong>NF4 (NormalFloat 4-bit)&lt;/strong>. The intuition: the weights of a trained transformer are distributed, empirically, very close to a &lt;strong>zero-centred Gaussian&lt;/strong>. INT4 spreads its 16 levels uniformly across the range, which wastes levels in the tails (where there are almost no weights) and leaves few in the centre (where they pile up). NF4 spreads the 16 levels according to the &lt;strong>quantiles&lt;/strong> of a normal distribution: more levels where there is more probability mass. It is, by construction, &lt;em>information-theoretically optimal&lt;/em> for exactly Gaussian data, each level covers roughly the same number of weights. It is also symmetric about zero and guarantees an exact representation of 0 (important for sparsity and padding). The detail of the quantisation formats is in &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a>; here the idea that NF4 spends its bits where the weights are is enough.&lt;/p>
&lt;h3 id="double-quantisation-and-paged-optimizers">Double quantisation and paged optimizers&lt;/h3>
&lt;p>Quantising to 4-bit is not entirely free: for each block of weights (typically 64) you need to store a scale constant in FP32 so the quantisation can be undone. Those constants weigh something. With blocks of 64 and one FP32 scale (32 bits) per block, that is &lt;code>32/64 = 0.5 bits per parameter&lt;/code> in metadata alone, a 12.5 % overhead on top of the 4 useful bits. &lt;strong>Double quantisation&lt;/strong> attacks that: it quantises the scale constants themselves (to 8-bit, in blocks of 256), bringing the overhead down to ~&lt;code>0.127 bits/param&lt;/code>. Quantising the quantisation sounds recursive and it is; the saving is small in absolute terms (~0.37 bits/param) but on an 8B that is hundreds of MB, which is exactly the margin separating &amp;ldquo;it fits&amp;rdquo; from &amp;ldquo;it does not fit&amp;rdquo; on a 4090.&lt;/p>
&lt;p>&lt;strong>Paged optimizers&lt;/strong> attack the memory peaks. During training, certain moments, a batch with a very long sequence, a large activation, push VRAM close to the limit and blow up with an OOM. The idea, borrowed from operating system paging, is to allocate the optimiser states in NVIDIA &lt;em>unified&lt;/em> memory: when VRAM gets tight, those pages are &lt;strong>evicted to host RAM&lt;/strong> automatically and brought back when needed. It speeds nothing up; it &lt;strong>avoids the crash&lt;/strong> at the peaks. It turns an &amp;ldquo;intermittent OOM&amp;rdquo; into &amp;ldquo;a bit slower at the worst moments&amp;rdquo;, which for an unattended training run on a single GPU is the difference between finishing and not finishing.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="QLoRA: frozen 4-bit base, BF16 adapter and the gradient flowing only through the adapter">
&lt;defs>&lt;marker id="qm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;marker id="qg" 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="#a52a2a"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="22" font-family="sans-serif" font-size="13" font-weight="600" fill="currentColor">Forward (blue) onwards · Gradient (red) only through the adapter&lt;/text>
&lt;rect x="30" y="120" width="90" height="44" rx="6" fill="#eef2f6" stroke="currentColor" stroke-width="1.4"/>
&lt;text x="75" y="140" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">x&lt;/text>
&lt;text x="75" y="156" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#555">input&lt;/text>
&lt;rect x="200" y="60" width="200" height="60" rx="8" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.6"/>
&lt;text x="300" y="84" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#0d3a66">W · x (frozen base)&lt;/text>
&lt;text x="300" y="102" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">NF4 4-bit · dequant on the fly · NO gradient&lt;/text>
&lt;rect x="200" y="170" width="200" height="84" rx="8" fill="#fff4d6" stroke="#a48000" stroke-width="1.6"/>
&lt;text x="300" y="192" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#7a5e00">LoRA adapter (BF16)&lt;/text>
&lt;rect x="220" y="202" width="74" height="40" rx="5" fill="#fffbe9" stroke="#a48000" stroke-width="1.2"/>
&lt;text x="257" y="220" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#7a5e00">A: r×d&lt;/text>
&lt;text x="257" y="234" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">shrink d→r&lt;/text>
&lt;rect x="306" y="202" width="74" height="40" rx="5" fill="#fffbe9" stroke="#a48000" stroke-width="1.2"/>
&lt;text x="343" y="220" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#7a5e00">B: d×r&lt;/text>
&lt;text x="343" y="234" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">expand r→d&lt;/text>
&lt;circle cx="500" cy="150" r="26" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.6"/>
&lt;text x="500" y="148" text-anchor="middle" font-family="sans-serif" font-size="15" font-weight="600" fill="#1d5a2e">+&lt;/text>
&lt;text x="500" y="163" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#1d5a2e">sum&lt;/text>
&lt;rect x="600" y="128" width="90" height="44" rx="6" fill="#eef2f6" stroke="currentColor" stroke-width="1.4"/>
&lt;text x="645" y="148" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">y&lt;/text>
&lt;text x="645" y="164" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#555">output&lt;/text>
&lt;path d="M120,142 L195,92" stroke="#1f5fa8" stroke-width="1.6" fill="none" marker-end="url(#qm)"/>
&lt;path d="M120,144 L195,205" stroke="#1f5fa8" stroke-width="1.6" fill="none" marker-end="url(#qm)"/>
&lt;path d="M400,90 L476,140" stroke="#1f5fa8" stroke-width="1.6" fill="none" marker-end="url(#qm)"/>
&lt;path d="M400,210 L476,162" stroke="#1f5fa8" stroke-width="1.6" fill="none" marker-end="url(#qm)"/>
&lt;path d="M526,150 L598,150" stroke="#1f5fa8" stroke-width="1.6" fill="none" marker-end="url(#qm)"/>
&lt;path d="M495,178 C470,250 410,258 386,256" stroke="#a52a2a" stroke-width="1.8" fill="none" stroke-dasharray="6 3" marker-end="url(#qg)"/>
&lt;text x="430" y="290" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#a52a2a">∂L/∂A , ∂L/∂B — the gradient only enters the adapter&lt;/text>
&lt;text x="300" y="50" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#1f5fa8">the base gets NO gradient: that is why it can live in 4-bit&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="aggressive-training-very-low-rank-and-qa-lora">&amp;ldquo;Aggressive&amp;rdquo; training: very low rank and QA-LoRA&lt;/h2>
&lt;p>&amp;ldquo;Aggressive&amp;rdquo; in this context means two things, sometimes combined.&lt;/p>
&lt;p>&lt;strong>Very low rank (r = 4-8).&lt;/strong> The rank is the bottleneck of the correction: how much &amp;ldquo;capacity&amp;rdquo; the adapter has to steer the base away from itself. A high rank (64, 128) brings the adapter closer to a full fine-tuning but weighs more and takes longer to train. For an SLM adapted to a narrow, well-defined task, an output format, a lexical domain, a response style, a rank of 4-8 is usually enough, and the resulting adapter weighs a fraction. The risk of a low rank is &lt;em>underfitting&lt;/em>: if the task demands rewriting a lot of the base&amp;rsquo;s behaviour, r=4 falls short. The honest rule is empirical: raise the rank only if the eval asks for it, not &amp;ldquo;just in case&amp;rdquo;. On small SLMs, where the base has less spare capacity, a low rank tends to work proportionally better than on large models, but this depends on the task and has to be measured, not assumed.&lt;/p>
&lt;p>&lt;strong>QA-LoRA (quantization-aware LoRA, Xu et al., arXiv:2309.14717).&lt;/strong> There is a subtle friction in standard QLoRA: you train the adapter in BF16 against a 4-bit base, but if you then want to &lt;strong>merge&lt;/strong> the adapter into the base (&lt;code>W' = W + BA&lt;/code>) to serve a clean quantised model, the merge reintroduces precision that the 4-bit format cannot represent, and on requantising you lose part of what was learnt. QA-LoRA trains the adapter &lt;strong>aware of the target&amp;rsquo;s quantisation&lt;/strong>: it balances the degrees of freedom of the quantisation and of the adaptation (with group-wise quantisation) so that, when it finishes, the adapter &lt;strong>merges cleanly&lt;/strong> into a quantised base without a requantisation step that degrades it. The result is a final model that is quantised-plus-adapted, with no separate adapter at runtime, useful when you want a single deployable artefact per task instead of the shared-base + adapters pattern. The choice between &amp;ldquo;QLoRA + serve multi-adapter&amp;rdquo; and &amp;ldquo;QA-LoRA + merge per task&amp;rdquo; is a deployment architecture decision, not one of pure quality.&lt;/p>
&lt;h2 id="the-maths-that-matters">The maths that matters&lt;/h2>
&lt;p>Three calculations drive any QLoRA decision on SLMs.&lt;/p>
&lt;p>&lt;strong>Adapter parameters.&lt;/strong> For each target matrix of dimension &lt;code>d&lt;/code> with rank &lt;code>r&lt;/code>, the adapter contributes &lt;code>A&lt;/code> (r×d) plus &lt;code>B&lt;/code> (d×r), that is &lt;code>2·r·d&lt;/code> parameters. Summing over the target matrices and multiplying by the number of layers:&lt;/p>
$$\text{params}_{\text{adapter}} = L \cdot \sum_{\text{matrices}} 2 \cdot r \cdot d$$
&lt;p>&lt;strong>Worked example — Llama-3-8B, attention (q, k, v, o), &lt;code>d = 4096&lt;/code>, &lt;code>L = 32&lt;/code> layers, &lt;code>r = 8&lt;/code>.&lt;/strong> Taking the four attention projections with the same &lt;code>d = 4096&lt;/code> (a simplification; in Llama-3 K and V are narrower because of GQA, which gives even fewer params):&lt;/p>
$$\text{params} \approx 32 \cdot 4 \cdot (2 \cdot 8 \cdot 4096) = 32 \cdot 4 \cdot 65\,536 \approx 8.4\text{M params}$$
&lt;p>In BF16 (2 bytes/param): &lt;code>8.4M · 2 ≈ 16.8 MB ≈ ~17 MB&lt;/code>. &lt;strong>Seventeen megabytes.&lt;/strong> Compare that with the base: an 8B in NF4 takes &lt;code>8\text{G} · 0.5\,\text{bytes} ≈ 4\text{ GB}&lt;/code> (plus the small constants overhead after double quantisation). The adapter is &lt;strong>0.4 %&lt;/strong> of the size of the quantised base. This is what makes having hundreds operationally trivial: an adapter is not a model, it is almost a heavy configuration file.&lt;/p>
&lt;p>&lt;strong>How many adapters fit on a 4090 after the base + KV?&lt;/strong> Budget for an RTX 4090 (24 GB): 8B NF4 base ~4 GB, leave ~5 GB for KV cache and inference activations at moderate concurrency → that leaves &lt;strong>~15 GB free&lt;/strong> (being conservative, call it ~12-15 GB). With ~17 MB adapters (r=8, attention-only):&lt;/p>
$$\frac{15\,000\ \text{MB}}{17\ \text{MB/adapter}} \approx 880 \text{ adapters}$$
&lt;p>On the order of thousands if you cut the reserved KV cache or use rank 4 (~8.5 MB/adapter → ~1750 in 15 GB). The bottleneck is never the adapters&amp;rsquo; space; it is the KV cache and concurrency. For the details of how those thousands are served concurrently, the heterogeneous batching, the unified paging, the SGMV kernels, see &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>. The relevant summary here: &lt;strong>the adapter compute is nearly free&lt;/strong> (low rank, two thin matmuls); the serving performance challenge is not that compute but the &lt;em>gather/scatter&lt;/em> of the right adapters per batch row when a single batch mixes requests from different adapters. That is the consumer&amp;rsquo;s problem, not the producer&amp;rsquo;s.&lt;/p>
&lt;p>&lt;strong>QLoRA training VRAM in 24 GB.&lt;/strong> The rough budget to fine-tune the 8B on a 4090:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Approx. VRAM&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>8B base in NF4 (frozen weights)&lt;/td>
&lt;td>~4.0 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Adapter (BF16 params + gradient + Adam states, ~16 B/param over ~8-40M params)&lt;/td>
&lt;td>~0.3-0.7 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Activations (depends on batch and sequence length; the variable bulk)&lt;/td>
&lt;td>~6-14 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dequant buffers, scales, workspace&lt;/td>
&lt;td>~1-2 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
&lt;td>&lt;strong>fits in 24 GB with margin&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The large, variable piece is the activations, which scale with batch × sequence length. That is why real QLoRA on a 4090 is done with a small batch + &lt;em>gradient accumulation&lt;/em> (simulating a large batch by accumulating gradients over microbatches) + &lt;em>gradient checkpointing&lt;/em> (recomputing activations in the backward pass instead of storing them, trading compute for memory) + bounded sequences. The &lt;strong>paged optimizers&lt;/strong> are the airbag for the activation peaks that would otherwise blow up. The claim &amp;ldquo;QLoRA fine-tunes an 8B on a 4090&amp;rdquo; is true with that configuration; with long sequences, a large batch or a high rank, it does not fit. As with any number, the methodology matters more than the headline.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A shared 4-bit base SLM with N adapters per customer, heterogeneous batching">
&lt;defs>&lt;marker id="bm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="22" font-family="sans-serif" font-size="13" font-weight="600" fill="currentColor">Heterogeneous batch: 4 requests, 3 customers, 3 adapters — one shared base SLM&lt;/text>
&lt;rect x="20" y="40" width="120" height="36" rx="5" fill="#f6e0c8" stroke="#a76b1f" stroke-width="1.3"/>
&lt;text x="80" y="63" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#7a4d12">req_1 → customer A&lt;/text>
&lt;rect x="150" y="40" width="120" height="36" rx="5" fill="#f6e0c8" stroke="#a76b1f" stroke-width="1.3"/>
&lt;text x="210" y="63" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#7a4d12">req_2 → customer A&lt;/text>
&lt;rect x="280" y="40" width="120" height="36" rx="5" fill="#f6e0c8" stroke="#a76b1f" stroke-width="1.3"/>
&lt;text x="340" y="63" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#7a4d12">req_3 → customer B&lt;/text>
&lt;rect x="410" y="40" width="120" height="36" rx="5" fill="#f6e0c8" stroke="#a76b1f" stroke-width="1.3"/>
&lt;text x="470" y="63" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#7a4d12">req_4 → customer C&lt;/text>
&lt;rect x="20" y="110" width="510" height="50" rx="8" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.6"/>
&lt;text x="275" y="132" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#0d3a66">BASE SLM — Llama-3-8B NF4 (~4 GB) — loaded ONCE, shared&lt;/text>
&lt;text x="275" y="150" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">W·x is computed the same for all 4 requests, whatever the adapter&lt;/text>
&lt;text x="560" y="100" font-family="sans-serif" font-size="11" font-weight="600" fill="currentColor">Pedalboard&lt;/text>
&lt;text x="560" y="114" font-family="sans-serif" font-size="10" fill="#555">(adapters ~17 MB)&lt;/text>
&lt;rect x="560" y="120" width="200" height="120" rx="6" fill="#fff4d6" stroke="#a48000" stroke-width="1.4"/>
&lt;rect x="572" y="132" width="176" height="22" rx="3" fill="#fffbe9" stroke="#a48000" stroke-width="1"/>&lt;text x="660" y="147" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">adapter A (customer A)&lt;/text>
&lt;rect x="572" y="160" width="176" height="22" rx="3" fill="#fffbe9" stroke="#a48000" stroke-width="1"/>&lt;text x="660" y="175" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">adapter B (customer B)&lt;/text>
&lt;rect x="572" y="188" width="176" height="22" rx="3" fill="#fffbe9" stroke="#a48000" stroke-width="1"/>&lt;text x="660" y="203" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">adapter C (customer C)&lt;/text>
&lt;text x="660" y="228" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">... thousands more, MB each&lt;/text>
&lt;path d="M80,76 L200,108" stroke="#666" stroke-width="1.3" fill="none" marker-end="url(#bm)"/>
&lt;path d="M210,76 L240,108" stroke="#666" stroke-width="1.3" fill="none" marker-end="url(#bm)"/>
&lt;path d="M340,76 L300,108" stroke="#666" stroke-width="1.3" fill="none" marker-end="url(#bm)"/>
&lt;path d="M470,76 L360,108" stroke="#666" stroke-width="1.3" fill="none" marker-end="url(#bm)"/>
&lt;text x="20" y="200" font-family="sans-serif" font-size="11" fill="#555">The adapter delta is applied per batch row:&lt;/text>
&lt;text x="20" y="216" font-family="sans-serif" font-size="11" fill="#555">reqs 1-2 → adapter A · req 3 → adapter B · req 4 → adapter C&lt;/text>
&lt;text x="20" y="232" font-family="sans-serif" font-size="11" font-weight="600" fill="#a52a2a">The challenge is NOT the delta compute (nearly free) — it is the heterogeneous gather/scatter.&lt;/text>
&lt;text x="20" y="270" font-family="sans-serif" font-size="10" fill="#555">Internals (SGMV, unified paging, heterogeneous batching): see Multi-LoRA serving.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-fit-with-small-models-and-sovereignty">The fit with small models and sovereignty&lt;/h2>
&lt;p>This is where QLoRA + SLM stops being a VRAM trick and becomes an architectural pattern.&lt;/p>
&lt;p>An SLM (3-8B) already fits comfortably on a single GPU for inference. If on top of that the base lives in 4-bit (~4 GB for an 8B), you have memory to spare. What QLoRA enables is for that same machine, the 4090, to be &lt;strong>both the producer and the consumer&lt;/strong>: you train a new customer&amp;rsquo;s adapter in hours, on the same class of hardware where you then serve it. The artefact that travels between &amp;ldquo;train&amp;rdquo; and &amp;ldquo;deploy&amp;rdquo; is an adapter of &lt;strong>MB, not GB&lt;/strong>: it is versioned, signed, moved across the network, stored in MinIO/S3 without thinking about the cost.&lt;/p>
&lt;p>The sovereign pattern falls out on its own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Per-customer isolation.&lt;/strong> Each customer has their adapter, trained only on their data. The base is generic and shared; what is specific to the customer lives isolated in their &lt;code>(A, B)&lt;/code> pair. Deleting a customer is deleting a file of a few MB, not retraining anything.&lt;/li>
&lt;li>&lt;strong>Minimum footprint.&lt;/strong> One base + N adapters fits where N bases would not come close. The economics of &amp;ldquo;one model per customer&amp;rdquo; (tens of GB each) are prohibitive; those of &amp;ldquo;one base + adapters&amp;rdquo; (MB each) are trivial. It is exactly the difference between the pedalboard and buying a guitar per song.&lt;/li>
&lt;li>&lt;strong>Sovereign deployment.&lt;/strong> Everything fits on-premise, on your hardware, without a single data item leaving the perimeter. Training (QLoRA on the 4090) and serving (multi-LoRA on the same base) live inside. There is no dependency on an external API for fine-tuning or for serving.&lt;/li>
&lt;/ul>
&lt;p>The choice between &lt;strong>adapting by domain&lt;/strong> (one adapter per knowledge area) and &lt;strong>retrieving by context&lt;/strong> (RAG that injects the knowledge into the prompt) is real and not mutually exclusive: the adapter changes the model&amp;rsquo;s &lt;em>behaviour&lt;/em> and &lt;em>style&lt;/em>, RAG changes the &lt;em>facts&lt;/em> it has access to. The sibling post on &lt;strong>aggressive RAG on small models&lt;/strong> in this series works through it; the short rule is: adapt what is stable and behavioural, retrieve what is volatile and factual.&lt;/p>
&lt;h2 id="applied-to-on-premise-infrastructure">Applied to on-premise infrastructure&lt;/h2>
&lt;h3 id="on-an-rtx-4090-24-gb-ada-lovelace">On an RTX 4090 (24 GB, Ada Lovelace)&lt;/h3>
&lt;p>This is QLoRA&amp;rsquo;s natural workbench. The canonical case: &lt;strong>a 3-8B base SLM in NF4, fine-tuning an r=8 attention-only adapter&lt;/strong>, with gradient checkpointing + gradient accumulation + paged optimizer. It trains in hours for narrow-task datasets (thousands to tens of thousands of examples), and the same machine then serves the base + tens or hundreds of adapters for multi-tenant demos and platform prototypes. The 4090 is where QLoRA went from &amp;ldquo;a paper technique&amp;rdquo; to &amp;ldquo;anyone with a consumer GPU can do it&amp;rdquo;, and that is exactly its value. The honest rule: it fits &lt;strong>with&lt;/strong> the memory configuration described; with long sequences, a large batch or a high rank, move up the hardware.&lt;/p>
&lt;h3 id="on-a-generic-4h100-sxm-cluster-320-gb-nvlink-native-fp8">On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/h3>
&lt;p>Here QLoRA stops being strictly necessary in order to &lt;em>fit&lt;/em>, an 8B in BF16 has room to spare, but it remains useful for another reason: &lt;strong>parallelising adapter production&lt;/strong>. With 320 GB and native FP8 you can train several adapters at once (one job per customer, several in parallel), or fine-tune somewhat larger models with QLoRA without TP. The consumer on this cluster is the serious setup from &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>: an FP8 base + hundreds of concurrent adapters. The rule of thumb: on the 4090, QLoRA is the tool to &lt;em>be able to&lt;/em> fine-tune; on the H100 cluster, it is the tool to fine-tune &lt;em>many at once, cheaply&lt;/em>, keeping the quantised format consistent between training and serving.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The internals of heterogeneous serving&lt;/strong> (SGMV kernels, MBGMM/MBGMV, unified paging, cold start, eviction): they are covered in full in &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>. This post is deliberately the producer&amp;rsquo;s side.&lt;/li>
&lt;li>&lt;strong>DoRA and variants&lt;/strong> (magnitude-direction decomposition): they close part of the gap with full fine-tuning; different training pattern, identical serving pattern.&lt;/li>
&lt;li>&lt;strong>Sub-4-bit and ternary quantisation of the base&lt;/strong>: what happens when the base drops from NF4 to 2-bit or ternary underneath the adapter; the sibling post in the series works through it.&lt;/li>
&lt;li>&lt;strong>Collecting the fine-tuning dataset&lt;/strong>: how each adapter&amp;rsquo;s corpus is built from production feedback is in &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/qlora-runbook-dataset-to-served-adapter/">QLoRA runbook: from dataset to served adapter&lt;/a> — the operational companion to this post: the step-by-step executable procedure (environment, TRL/PEFT script, monitoring, versioning and serving in vLLM with hot loading). With commands.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a> — the consumer: the internals of how thousands of concurrent adapters are served (SGMV, unified paging, heterogeneous batching). Read it: this post assumes everything about serving is known.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a> — the framework of formats (NF4, INT4, FP8, AWQ) that holds up the quantised base underneath the adapter.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">Knowledge distillation&lt;/a> — the alternative/complement to adapting: compressing the knowledge into the model itself instead of into an adapter on top.&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 operational cycle that continuously produces new adapters from production signal.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the feedback → dataset → adapter loop&lt;/a> — where the dataset each QLoRA adapter is trained on comes from.&lt;/li>
&lt;li>&lt;strong>Inverted roofline on small models&lt;/strong> (sibling in the series) — the performance regime an SLM operates in, which explains why the adapter&amp;rsquo;s minimum footprint fits consumer GPUs.&lt;/li>
&lt;li>&lt;strong>Aggressive sub-4-bit / ternary quantisation&lt;/strong> (sibling in the series) — what happens to the quantised base below NF4 underneath the adapter.&lt;/li>
&lt;li>&lt;strong>Aggressive RAG on small models&lt;/strong> (sibling in the series) — adapting by domain (this post) versus retrieving by context; when to use each.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Dettmers, T., Pagnoni, A., Holtzman, A., Zettlemoyer, L. &lt;em>QLoRA: Efficient Finetuning of Quantized LLMs&lt;/em>. NeurIPS 2023. &lt;a href="https://arxiv.org/abs/2305.14314">https://arxiv.org/abs/2305.14314&lt;/a>&lt;/li>
&lt;li>Hu, E., Shen, Y., Wallis, P., Allen-Zhu, Z., Li, Y., Wang, S., Wang, L., Chen, W. &lt;em>LoRA: Low-Rank Adaptation of Large Language Models&lt;/em>. ICLR 2022. &lt;a href="https://arxiv.org/abs/2106.09685">https://arxiv.org/abs/2106.09685&lt;/a>&lt;/li>
&lt;li>Xu, Y., Xie, L., Gu, X., Chen, X., Chang, H., Zhang, H., Chen, Z., Zhang, X., Tian, Q. &lt;em>QA-LoRA: Quantization-Aware Low-Rank Adaptation of Large Language Models&lt;/em>. ICLR 2024. &lt;a href="https://arxiv.org/abs/2309.14717">https://arxiv.org/abs/2309.14717&lt;/a>&lt;/li>
&lt;li>Sheng, Y. et al. &lt;em>S-LoRA: Serving Thousands of Concurrent LoRA Adapters&lt;/em>. MLSys 2024. &lt;a href="https://arxiv.org/abs/2311.03285">https://arxiv.org/abs/2311.03285&lt;/a>&lt;/li>
&lt;li>Chen, L. et al. &lt;em>Punica: Multi-Tenant LoRA Serving&lt;/em>. MLSys 2024. &lt;a href="https://arxiv.org/abs/2310.18547">https://arxiv.org/abs/2310.18547&lt;/a>&lt;/li>
&lt;li>Official QLoRA / bitsandbytes repo: &lt;a href="https://github.com/artidoro/qlora">https://github.com/artidoro/qlora&lt;/a>&lt;/li>
&lt;li>Hugging Face PEFT (LoRA, QLoRA): &lt;a href="https://github.com/huggingface/peft">https://github.com/huggingface/peft&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Multi-LoRA serving: the single translator with a thousand glossaries — shared base, thousands of concurrent adapters and the SGMV kernel</title><link>https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/</link><pubDate>Sat, 30 May 2026 14:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/</guid><description>&lt;blockquote>
&lt;p>This post complements &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a>. Continuous fine-tuning is the producer of the adapters; multi-LoRA serving is the consumer that puts them to work. Without this layer, the whole feedback cycle breaks in the last mile. It also crosses over with &lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment (DPO/KTO/ORPO/SimPO)&lt;/a> (each alignment policy can live as a separate adapter) and &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation&lt;/a> (a quantised base frees memory for many more adapters).&lt;/p>
&lt;/blockquote>
&lt;h2 id="you-are-here-deploy">You are here: DEPLOY&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7ad88f;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#mlm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mlm)}&lt;/style>
&lt;defs>&lt;marker id="mlm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY · shared base, N concurrent adapters on a single GPU&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The dominant pattern in 2026 is not one model per customer but a single general-purpose base model plus N fine LoRA adapters per task, customer, language or domain. The reason is obvious: a rank-16 LoRA over Llama-3-70B takes about 400 MB; a full fine-tune takes about 140 GB. Dozens or hundreds of adapters per base is manageable; dozens or hundreds of bases is prohibitive. What is not obvious is how to serve them concurrently without reloading weights every time the adapter changes (killing batching) or replicating the base (killing memory). The answer crystallised in 2024 with two complementary papers: &lt;strong>S-LoRA&lt;/strong> (Sheng et al., Stanford + UC Berkeley, MLSys 2024) introduced &lt;em>unified paging&lt;/em>, where adapter weights live in the same memory pool as the KV cache, both pageable, and &lt;em>heterogeneous batching&lt;/em>, where a batch can hold requests with different adapters and different ranks without padding; &lt;strong>Punica&lt;/strong> (Chen et al., UW + Duke, MLSys 2024) introduced the CUDA kernel that has become the de facto standard: &lt;strong>SGMV&lt;/strong> (Segmented Gather Matrix-Vector multiplication), which computes &lt;code>Y += Σ_i X_i · A_i · B_i&lt;/code> in a single pass, grouping requests by adapter. SGMV sits today underneath vLLM, LoRAX (Predibase), SGLang and TGI. The measurable operational result: up to &lt;strong>2,000 concurrent adapters on a single GPU&lt;/strong> (S-LoRA paper), up to 4× throughput vs naive vLLM and up to 30× vs HuggingFace PEFT. The price: typical overhead of &lt;strong>10-30 %&lt;/strong> latency per layer with an active adapter in a heterogeneous batch, practically zero when every request in the batch uses the same adapter, and 20-40 % in the worst case. This post takes apart the mechanism, the maths (memory per adapter, overhead per rank), the comparison table of implementations, the pitfalls (cold start, mismatched rank, fragmentation) and the real economics on an H100 with a Llama-3-70B FP8 base + 200 adapters.&lt;/p>
&lt;h2 id="the-analogy-the-single-translator-with-a-thousand-glossaries">The analogy: the single translator with a thousand glossaries&lt;/h2>
&lt;p>Imagine a specialised translation agency with a single senior translator, brilliant, fluent in fifteen languages and across all the general technical domains. That translator is expensive to hire and expensive to train: it took years of formation and experience that is not easily replicated. But the agency receives texts from very different clients: a law firm using legal terminology specific to its jurisdiction, a manufacturer with an internal parts nomenclature, a hospital with its own clinical abbreviations. Every client has its own jargon.&lt;/p>
&lt;p>The agency does not hire one translator per client; that would be ridiculous, 90 % of the work is shared. What it does is &lt;strong>keep one glossary per client&lt;/strong>: a small notebook, easy to update, containing the specific terms and how they are translated for that client. When the translator receives a text, they open the glossary for the client in question and work with it alongside. As they translate each word, they first check whether it is in the glossary; if it is, they use the specific version; if not, they use their general knowledge.&lt;/p>
&lt;p>The glossaries live on a shared shelf, ordered by recent use: the most consulted ones within reach, the old ones in the archive. When a new client arrives, their glossary is brought from the archive to the shelf. When the desk fills up, the least used glossary goes back to the archive.&lt;/p>
&lt;p>And the most important part: the translator can have several glossaries open at once because they are working in parallel on five texts from five clients. It is not one glossary per document; it is one glossary per client, and client A&amp;rsquo;s documents use A&amp;rsquo;s glossary, client B&amp;rsquo;s use B&amp;rsquo;s, all on the same desk.&lt;/p>
&lt;p>The analogy holds on five mappings:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The single translator&lt;/strong> = the base model (Llama-3-70B, Qwen2.5-72B). Expensive to train, a single copy in VRAM.&lt;/li>
&lt;li>&lt;strong>Each glossary&lt;/strong> = a LoRA adapter. Small (~150-400 MB), specific, easy to update.&lt;/li>
&lt;li>&lt;strong>The shelf with the glossaries within reach&lt;/strong> = the pool of adapters cached in VRAM (typically 50-200 at a time with an FP8 base on an H100 80 GB).&lt;/li>
&lt;li>&lt;strong>The archive&lt;/strong> = adapter storage on MinIO/S3/HF Hub. Hundreds or thousands, fetched on demand.&lt;/li>
&lt;li>&lt;strong>Working in parallel with several glossaries open&lt;/strong> = a heterogeneous batch with SGMV. The kernel does the grouped lookup into the right glossary for each word in the batch.&lt;/li>
&lt;/ul>
&lt;h2 id="the-bare-mechanism-what-a-lora-does-and-why-it-can-be-served-multi-tenant">The bare mechanism: what a LoRA does and why it can be served multi-tenant&lt;/h2>
&lt;p>A LoRA adapter modifies a matrix &lt;code>W&lt;/code> of the base model by adding a low-rank product to it:&lt;/p>
$$W' = W + B A$$
&lt;p>where &lt;code>W ∈ R^{d_out × d_in}&lt;/code> is the original matrix (the base weights), &lt;code>A ∈ R^{r × d_in}&lt;/code> and &lt;code>B ∈ R^{d_out × r}&lt;/code> are the adapter&amp;rsquo;s trainable matrices, and &lt;code>r&lt;/code> is the &lt;strong>rank&lt;/strong> (typically 8, 16, 32 or 64, always much smaller than &lt;code>d_in&lt;/code> and &lt;code>d_out&lt;/code>).&lt;/p>
&lt;p>In a forward pass, instead of computing &lt;code>y = W' x&lt;/code>, it computes:&lt;/p>
$$y = W x + B(Ax)$$
&lt;p>That is: the base computation (&lt;code>Wx&lt;/code>) happens exactly as before; the adapter adds two cheap matmuls (&lt;code>Ax&lt;/code> and then &lt;code>B(·)&lt;/code>) that supply the correction. The matrix &lt;code>BA&lt;/code> is never explicitly materialised.&lt;/p>
&lt;p>&lt;strong>What this enables in serving&lt;/strong>: if you have the base loaded and N different adapters, &lt;code>Wx&lt;/code> is computed only once for all the tokens in the batch (the base is the same). What changes between tokens is only the delta &lt;code>B_i(A_i x)&lt;/code>. If the tokens in the batch use different adapters, different deltas have to be applied per token, and that is what the SGMV kernel does in a single pass.&lt;/p>
&lt;p>Without a specialised kernel this degenerates: you need to launch N separate matmuls (one per adapter), pay kernel launch overhead N times and lose the batching. With a specialised kernel (SGMV), every delta is computed in a single pass grouped by adapter.&lt;/p>
&lt;h2 id="sgmv-the-kernel-that-holds-it-all-up">SGMV: the kernel that holds it all up&lt;/h2>
&lt;p>&lt;strong>SGMV&lt;/strong> (Segmented Gather Matrix-Vector multiplication) is the CUDA kernel Punica introduced and that vLLM, LoRAX, SGLang and TGI have adopted as their multi-LoRA engine.&lt;/p>
&lt;p>Its job is to compute, given a batch of tokens with mixed adapters:&lt;/p>
$$y_t = W x_t + B_{a(t)} A_{a(t)} x_t \quad \forall t \in \text{batch}$$
&lt;p>where &lt;code>a(t)&lt;/code> is the adapter assigned to token &lt;code>t&lt;/code>. SGMV operates in two phases:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>SGMV-shrink&lt;/strong>: projection &lt;code>d_in → r&lt;/code> with the corresponding &lt;code>A_{a(t)}&lt;/code> matrix.&lt;/li>
&lt;li>&lt;strong>SGMV-expand&lt;/strong>: projection &lt;code>r → d_out&lt;/code> with the corresponding &lt;code>B_{a(t)}&lt;/code> matrix.&lt;/li>
&lt;/ol>
&lt;p>Internally, SGMV groups the batch&amp;rsquo;s tokens by adapter (it &lt;code>segments&lt;/code> them), and for each segment it uses the optimal kernel for the size: for large segments (several requests with the same adapter) it goes through tensor cores; for small segments (one request per adapter) it uses the &lt;em>batched gather&lt;/em> path that minimises launch overhead.&lt;/p>
&lt;p>The result, in a single kernel pass, is the correct delta for every token in the batch, whatever its rank or its adapter. Punica reported up to 12× throughput vs vLLM/FasterTransformer/HF Transformers/DeepSpeed in heterogeneous multi-tenant scenarios; when every request uses the same adapter, SGMV is practically equivalent to the base without LoRA because it reduces to the &amp;ldquo;single large segment&amp;rdquo; case that is optimal for tensor cores.&lt;/p>
&lt;p>S-LoRA refined SGMV with two kernels specific to different serving phases: &lt;strong>MBGMM&lt;/strong> (Multi-size Batched Gather Matrix-Matrix) for prefill, &lt;strong>MBGMV&lt;/strong> for decode. Both support different ranks among requests in the same batch, which was a limitation in the original SGMV.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Multi-LoRA serving with SGMV kernel and hierarchical cache">
&lt;style>
.base{fill:#d4ecff;stroke:#1f5fa8;stroke-width:1.4;rx:8}
.gpu{fill:#fff4d6;stroke:#a48000;stroke-width:1.2;rx:4}
.cpu{fill:#cdebd0;stroke:#2a7a40;stroke-width:1.2;rx:4}
.s3{fill:#e6d0ff;stroke:#5a2db0;stroke-width:1.2;rx:4}
.req{fill:#f6e0c8;stroke:#a76b1f;stroke-width:1.2;rx:4}
.kern{fill:#f6caca;stroke:#a52a2a;stroke-width:1.4;rx:6}
.lbl{font:600 12px sans-serif;fill:#222}
.sub{font:400 10px sans-serif;fill:#555}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#mlk)}
.evict{stroke:#999;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mlk)}
&lt;/style>
&lt;defs>&lt;marker id="mlk" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#444"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="22" class="lbl">1. Heterogeneous batch: four requests with three different adapters&lt;/text>
&lt;rect x="20" y="35" width="110" height="35" class="req"/>&lt;text x="75" y="55" text-anchor="middle" class="lbl">req_1 → A12&lt;/text>&lt;text x="75" y="68" text-anchor="middle" class="sub">tenant_1&lt;/text>
&lt;rect x="140" y="35" width="110" height="35" class="req"/>&lt;text x="195" y="55" text-anchor="middle" class="lbl">req_2 → A12&lt;/text>&lt;text x="195" y="68" text-anchor="middle" class="sub">tenant_1 (another chat)&lt;/text>
&lt;rect x="260" y="35" width="110" height="35" class="req"/>&lt;text x="315" y="55" text-anchor="middle" class="lbl">req_3 → A47&lt;/text>&lt;text x="315" y="68" text-anchor="middle" class="sub">tenant_2&lt;/text>
&lt;rect x="380" y="35" width="110" height="35" class="req"/>&lt;text x="435" y="55" text-anchor="middle" class="lbl">req_4 → A89&lt;/text>&lt;text x="435" y="68" text-anchor="middle" class="sub">tenant_3&lt;/text>
&lt;text x="20" y="105" class="lbl">2. SGMV kernel: groups by adapter, computes in a single pass&lt;/text>
&lt;rect x="20" y="115" width="470" height="60" class="kern"/>
&lt;text x="255" y="138" text-anchor="middle" class="lbl">SGMV: Y = Wx (base) + Σ_a B_a · A_a · x_{tokens(a)}&lt;/text>
&lt;text x="255" y="156" text-anchor="middle" class="sub">segment A12 (2 reqs, rank=16) | segment A47 (1 req, rank=8) | segment A89 (1 req, rank=32)&lt;/text>
&lt;text x="255" y="169" text-anchor="middle" class="sub">tensor cores for the large segment, batched gather for the small ones&lt;/text>
&lt;text x="20" y="200" class="lbl">3. GPU memory: shared base + unified adapter pool + KV cache&lt;/text>
&lt;rect x="20" y="210" width="470" height="50" class="base"/>
&lt;text x="255" y="230" text-anchor="middle" class="lbl">BASE: Llama-3-70B FP8 (~70 GB) — loaded once, shared by all&lt;/text>
&lt;text x="255" y="248" text-anchor="middle" class="sub">computes Wx for every token in the batch whatever the adapter&lt;/text>
&lt;rect x="20" y="270" width="220" height="55" class="gpu"/>
&lt;text x="130" y="290" text-anchor="middle" class="lbl">HBM POOL: ~10 GB free&lt;/text>
&lt;text x="130" y="305" text-anchor="middle" class="sub">~25 active r=16 adapters (hot)&lt;/text>
&lt;text x="130" y="318" text-anchor="middle" class="sub">A12, A47, A89, A03, A18, A23, ...&lt;/text>
&lt;rect x="270" y="270" width="220" height="55" class="cpu"/>
&lt;text x="380" y="290" text-anchor="middle" class="lbl">RAM CACHE: ~512 GB&lt;/text>
&lt;text x="380" y="305" text-anchor="middle" class="sub">~1,300 warm adapters&lt;/text>
&lt;text x="380" y="318" text-anchor="middle" class="sub">LRU eviction; async H2D on use&lt;/text>
&lt;text x="520" y="232" class="lbl">4. STORAGE&lt;/text>
&lt;rect x="520" y="240" width="240" height="85" class="s3"/>
&lt;text x="640" y="260" text-anchor="middle" class="lbl">MinIO / S3 / HF Hub&lt;/text>
&lt;text x="640" y="278" text-anchor="middle" class="sub">cold storage: thousands of adapters&lt;/text>
&lt;text x="640" y="293" text-anchor="middle" class="sub">A0001 ... A9999&lt;/text>
&lt;text x="640" y="308" text-anchor="middle" class="sub">cold start: ~0.5-5s per adapter&lt;/text>
&lt;path class="arr" d="M270,297 L240,297"/>
&lt;path class="evict" d="M240,310 L270,310"/>
&lt;path class="arr" d="M520,295 L490,295"/>
&lt;text x="20" y="355" class="sub">Solid arrow = on-demand path (cache miss). Dashed = LRU eviction.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-maths-that-matters">The maths that matters&lt;/h2>
&lt;p>Three numbers drive every operational decision with multi-LoRA.&lt;/p>
&lt;p>&lt;strong>Memory per adapter.&lt;/strong> For a &lt;code>d_in × d_out&lt;/code> matrix with rank &lt;code>r&lt;/code> and &lt;code>b&lt;/code> bytes per parameter (BF16/FP16 = 2):&lt;/p>
$$\text{bytes per matrix} = (d_{\text{in}} \cdot r + r \cdot d_{\text{out}}) \cdot b$$
&lt;p>Summing over all the target matrices in each layer and multiplying by the number of layers gives the adapter size. A concrete calculation for &lt;strong>Llama-3-70B&lt;/strong>, rank 16, BF16, &lt;strong>all&lt;/strong> the matrices (Q, K, V, O, gate, up, down):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Matrix&lt;/th>
&lt;th>Dimension&lt;/th>
&lt;th>Bytes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Q (8192→8192)&lt;/td>
&lt;td>8192·16 + 16·8192&lt;/td>
&lt;td>524,288&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>O (8192→8192)&lt;/td>
&lt;td>8192·16 + 16·8192&lt;/td>
&lt;td>524,288&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>K (8192→1024)&lt;/td>
&lt;td>8192·16 + 16·1024&lt;/td>
&lt;td>294,912&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>V (8192→1024)&lt;/td>
&lt;td>8192·16 + 16·1024&lt;/td>
&lt;td>294,912&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>gate (8192→28,672)&lt;/td>
&lt;td>8192·16 + 16·28,672&lt;/td>
&lt;td>1,179,648&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>up (8192→28,672)&lt;/td>
&lt;td>8192·16 + 16·28,672&lt;/td>
&lt;td>1,179,648&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>down (28,672→8192)&lt;/td>
&lt;td>28,672·16 + 16·8192&lt;/td>
&lt;td>1,179,648&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Sum per layer&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>5,177,344 ≈ 4.94 MB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Adapter total (80 layers)&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~395 MB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If it is limited to attention-only (Q, O, V, K): about 125 MB per adapter. The choice of which matrices get LoRA belongs to whoever trains it; in serving it is inherited and it determines the cost.&lt;/p>
&lt;p>&lt;strong>Memory per rank.&lt;/strong> Linear: rank 8 → ~200 MB; rank 16 → ~400 MB; rank 32 → ~800 MB; rank 64 → ~1.6 GB. The simple sizing rule is: &lt;code>max_lora_rank&lt;/code> should be the maximum rank you are going to serve, no more. Setting it higher wastes reserved memory in every slot.&lt;/p>
&lt;p>&lt;strong>How many adapters fit.&lt;/strong> For an H100 SXM 80 GB with a Llama-3-70B FP8 base (~70 GB), about 10 GB are left after a minimal KV cache → around 25 fully targeted r=16 adapters or around 80 attention-only. With an AWQ INT4 base (~35 GB), about 45 GB are left → hundreds of adapters. The rule: quantising the base does not just free memory, it multiplies the economics of the platform.&lt;/p>
&lt;p>&lt;strong>Latency overhead per adapter.&lt;/strong> Under real reported conditions:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Case&lt;/th>
&lt;th>Typical overhead&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>All requests in the batch on the same adapter&lt;/td>
&lt;td>~0 % (equivalent to a static merge)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Heterogeneous batch, similar ranks (e.g. all r=16)&lt;/td>
&lt;td>10-30 % per layer&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Heterogeneous batch, mismatched ranks (r=8 with r=128)&lt;/td>
&lt;td>up to +84 % P95 TTFT for the lower rank&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Naive PEFT LoRA (no SGMV)&lt;/td>
&lt;td>250-950 % extra&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>It scales linearly with rank: rank 8 ≈ baseline; rank 64 ≈ 3-4 × overhead.&lt;/p>
&lt;h2 id="the-real-implementations-in-may-2026">The real implementations in May 2026&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Implementation&lt;/th>
&lt;th>Base kernel&lt;/th>
&lt;th>Hot-swap&lt;/th>
&lt;th>Quantised base + LoRA&lt;/th>
&lt;th>Operational notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>vLLM&lt;/strong>&lt;/td>
&lt;td>SGMV + ext&lt;/td>
&lt;td>Yes (LoRAResolver, S3/HF/FS plugins)&lt;/td>
&lt;td>AWQ/GPTQ yes, bnb 4-bit offline only&lt;/td>
&lt;td>The de facto default. &lt;code>--enable-lora --max-loras N --max-lora-rank R --max-cpu-loras M&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LoRAX&lt;/strong> (Predibase)&lt;/td>
&lt;td>Optimised SGMV&lt;/td>
&lt;td>Yes (dynamic loading)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Designed specifically for multi-LoRA. Supports Medusa adapters per adapter (spec-dec per adapter).&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SGLang&lt;/strong>&lt;/td>
&lt;td>SGMV / csgmv&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;code>--enable-lora-overlap-loading&lt;/code> cuts TTFT by up to 78 % on LoRA-heavy workloads&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TensorRT-LLM&lt;/strong>&lt;/td>
&lt;td>LoRA Executor (C++)&lt;/td>
&lt;td>Pre-compile at build time&lt;/td>
&lt;td>INT4 + LoRA common&lt;/td>
&lt;td>Peak throughput on H100/B200, less flexible than vLLM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>HF TGI&lt;/strong>&lt;/td>
&lt;td>Punica fork&lt;/td>
&lt;td>Yes (&lt;code>LORA_ADAPTERS=...&lt;/code>)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>In &lt;em>maintenance mode&lt;/em> as of May 2026; HF recommends vLLM or SGLang&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>NVIDIA NIM&lt;/strong>&lt;/td>
&lt;td>TRT-LLM under the hood&lt;/td>
&lt;td>Static or dynamic (&lt;code>NIM_PEFT_REFRESH_INTERVAL&lt;/code>)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Adapter store per model; polling for hot-add/remove&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three operational observations:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>vLLM dominates&lt;/strong> open-source serving in 2026 thanks to the combination of a mature SGMV + LoRAResolver plugins + support for a quantised base. The critical parameter is &lt;code>--max-lora-rank&lt;/code>: many installations set it to the maximum &amp;ldquo;just in case&amp;rdquo; and waste memory silently.&lt;/li>
&lt;li>&lt;strong>LoRAX wins&lt;/strong> in production operations with thousands of rarely used adapters thanks to its dynamic loading that does not block concurrent requests. A public case: Convirza with 60+ concurrent adapters and sub-2s P95.&lt;/li>
&lt;li>&lt;strong>SGLang wins&lt;/strong> on latency when cold starts are frequent thanks to &lt;code>--enable-lora-overlap-loading&lt;/code> (async H2D during the compute of the previous request).&lt;/li>
&lt;/ol>
&lt;h2 id="combined-pattern-with-quantisation-and-disaggregated-serving">Combined pattern with quantisation and disaggregated serving&lt;/h2>
&lt;p>&lt;strong>With quantisation&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation&lt;/a>). The canonical stack in May 2026 is base in FP8 (Hopper/Blackwell) or INT4 AWQ + adapters in BF16/FP16. The adapters are not quantised: they are small, the memory saved is irrelevant, and quantisation noise would accumulate badly with the delta. Quantising the base frees massive amounts of memory for more adapters with no significant loss (&amp;lt;1 % in typical MMLU with AWQ INT4, somewhat more in math/code/reasoning). An adapter trained with a BF16 base works with an FP8/INT4 base at inference with marginal loss, and that is what makes QLoRA operationally trivial: train with a 4-bit base and deploy with a consistent 4-bit base.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a>). Multi-LoRA + prefill/decode disaggregation adds a management layer: each pod needs access to the request&amp;rsquo;s active adapter. The 2026 strategy: replicate hot adapters across every pod (prefill and decode), evict the cold ones. Cold adapters not present in the target pod are transferred on demand, accepting the extra cost in TTFT. Recent work (InfiniLoRA, FASTLIBRA, LoRAServe) automates this balancing, but the simple rule of thumb works in most deployments.&lt;/p>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;p>&lt;strong>Cold start.&lt;/strong> The first request to a sleeping adapter involves a fetch (from S3/MinIO/HF Hub) → CPU load → H2D copy. For an adapter of around 400 MB: 0.5-5 s typically, depending on bandwidth. Under high concurrency it can be up to 35 % of E2E latency (Predictive-LoRA paper, arXiv:2512.20210). Established mitigations: SGLang &lt;code>--enable-lora-overlap-loading&lt;/code> (cuts TTFT by 35-78 %); vLLM pre-warming with a dummy request when an adapter is registered; predictive prefetching based on patterns (Predictive-LoRA cuts cold start by 68 %).&lt;/p>
&lt;p>&lt;strong>Heterogeneous worst case.&lt;/strong> If every request in the batch has a different adapter and a different rank, SGMV loses its advantage because each segment has size 1. Throughput can fall by up to 50 % vs the base without LoRA. Practical mitigation: group adapters by rank in the upstream routing, trying to get requests of the same rank into the same batch step.&lt;/p>
&lt;p>&lt;strong>Mismatched rank.&lt;/strong> Co-batching rank 8 with rank 128 penalises the smaller one: &lt;strong>+84 % P95 TTFT&lt;/strong> for the rank-8 requests (Serving Heterogeneous LoRA Adapters paper, arXiv:2511.22880). Practice: normalise the rank across the fleet whenever possible (train every adapter at the same rank, or at least within a narrow range).&lt;/p>
&lt;p>&lt;strong>Eviction.&lt;/strong> LRU is the default. If you have more adapters than fit in CPU RAM, the evicted ones get fetched again from cold storage. Monitoring &lt;code>cold_starts_per_minute&lt;/code> and &lt;code>cache_hit_ratio&lt;/code> per endpoint is basic hygiene.&lt;/p>
&lt;p>&lt;strong>Base versioning.&lt;/strong> Every adapter is tied to an exact base version (Llama-3-70B-Instruct ≠ Llama-3.1-70B-Instruct). The routing must validate the base+adapter pair before serving, or the output will be silent rubbish.&lt;/p>
&lt;p>&lt;strong>Memory fragmentation.&lt;/strong> Without paged management (the pre-S-LoRA / pre-vLLM case), evicting and inserting adapters fragments HBM until it becomes unusable. Unified Paging solves it: the LoRA weights and the KV cache live in the same pool of blocks, interchangeable.&lt;/p>
&lt;h2 id="implications-on-on-premise-hardware">Implications on on-premise hardware&lt;/h2>
&lt;p>&lt;strong>On an RTX 4090 (24 GB).&lt;/strong> The classic case: base &lt;strong>Llama-3-8B FP8&lt;/strong> (~8 GB) or &lt;strong>Llama-3-8B AWQ-INT4&lt;/strong> (~5 GB) + dozens of r=16 adapters (~80 MB each, around 3 MB per adapter for Llama-3-8B with r=16 attention-only). 50-100 active adapters fit easily on the 4090. It is the natural setup for multi-tenant demos, fine-tuning over an 8B base and platform prototypes.&lt;/p>
&lt;p>&lt;strong>On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8).&lt;/strong> This is where the serious setup comes in:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama-3-70B FP8&lt;/strong> (~70 GB) fits on 2 H100s; the other 2 GPUs are available for batch + adapters.&lt;/li>
&lt;li>&lt;strong>Llama-3-70B AWQ-INT4&lt;/strong> (~35 GB) fits on 1 H100; the rest of the cluster serves more concurrency or more adapters.&lt;/li>
&lt;li>&lt;strong>~200 fully targeted r=16 adapters&lt;/strong> fit with a comfortable budget on the cluster, enough for a SaaS platform with dozens of tenants and simultaneous A/B testing.&lt;/li>
&lt;li>&lt;strong>QLoRA training + consistent serving&lt;/strong>: the train→deploy adapter cycle takes hours, not days, because the adapter is around 400 MB instead of around 140 GB.&lt;/li>
&lt;/ul>
&lt;p>The rule of thumb on an H100 cluster in May 2026: an FP8 or INT4 base + 100-500 adapters per cluster is operationally trivial with vLLM or LoRAX; going past a thousand concurrent adapters starts to require serious tuning of eviction and prefetch.&lt;/p>
&lt;h2 id="typical-production-stack">Typical production stack&lt;/h2>
&lt;pre tabindex="0">&lt;code>[API Gateway]
↓ (JWT with tenant_id / API key)
[Router]
↓ injects adapter_id into the request
[vLLM / LoRAX with --enable-lora]
--max-loras 16
--max-lora-rank 32
--max-cpu-loras 200
+ LoRAResolver → s3://adapters/{tenant_id}/{version}/
+ Base: Llama-3-70B-FP8 loaded once on 4×H100 TP=4
↓
[GPU]
FP8 base (70 GB) + ~150 BF16 adapters hot in HBM + ~1,000 warm in RAM
[MinIO / S3]
Cold storage for thousands of adapters
[CI pipeline]
→ trains new QLoRA adapter → push MinIO → notify server → warm-up
[Observability]
Prom: active_adapters, cache_hit_ratio, cold_starts_per_minute,
per_adapter_throughput, P99_with_lora vs P99_base
&lt;/code>&lt;/pre>&lt;p>Routing pattern: &lt;code>Authorization: Bearer &amp;lt;key&amp;gt;&lt;/code> → middleware extracts &lt;code>tenant_id&lt;/code> → maps to &lt;code>adapter_id&lt;/code> (Postgres or Redis) → &lt;code>POST /v1/completions&lt;/code> with &lt;code>model: &amp;quot;&amp;lt;adapter_id&amp;gt;&amp;quot;&lt;/code>.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>DoRA (Weight-Decomposed LoRA)&lt;/strong>: decomposes the update into magnitude + direction, closing part of the quality gap with full fine-tuning. Supported by TensorRT-LLM and others, but the serving pattern is identical to LoRA.&lt;/li>
&lt;li>&lt;strong>MoE + LoRA&lt;/strong>: how adapter fine-tuning is done over an MoE, what happens to the routing. Not trivial, an active research area in 2026.&lt;/li>
&lt;li>&lt;strong>Activated LoRA&lt;/strong> (arXiv:2512.17910): a variant that reuses the KV cache between compatible adapters, cutting the cost of cold start with shared prefixes.&lt;/li>
&lt;li>&lt;strong>LoRA for speculative decoding&lt;/strong>: each adapter brings its own Medusa head, supported by LoRAX as &amp;ldquo;Turbo LoRA&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Compress-then-Serve&lt;/strong> (arXiv:2407.00066): quantising the adapters themselves to serve even more concurrently. Still a marginal practice in production as of May 2026.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on a single GPU: co-residency, model-swapping and sleep mode&lt;/a> — the alternative when the models do not share a base: co-residency, swap or sleep instead of adapters.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — the producer of the adapter fleet; multi-LoRA serving is the consumer that closes the cycle.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the feedback → dataset → adapter loop&lt;/a> — how a new adapter is trained from production feedback; the resulting adapter is served with this stack.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO, SimPO&lt;/a> — each alignment policy can live as a separate adapter; multi-LoRA makes it possible to serve them in parallel and run A/B tests.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a> — quantising the base frees memory for many more adapters; the canonical stack is an FP8/INT4 base + BF16 adapters.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode&lt;/a> — once the pools are separated, adapters are managed per pod; the 2026 strategy is to replicate the hot ones everywhere and evict the cold ones.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference: the call centre with 256 specialists&lt;/a> — LoRA over an MoE base is an active area; the MoE routing complicates applying the deltas correctly.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — the critical operational tension of multi-LoRA: when every request in the batch uses a different adapter (and rank), throughput can fall by up to 50 %; the scheduler must group requests by rank and adapter to keep the economics.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output&lt;/a> — an adapter can be trained specifically for function calling or extraction; combined with XGrammar it gives the strong contract (an adapter suited to the task + a grammar that guarantees the schema).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where Deploy is stage 4.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and multi-LoRA at the limit on small models&lt;/a> — the training side: fine-tuning over an NF4 quantised base on a single 4090, aggressive ranks (r=4-8) and the &amp;ldquo;one SLM base + one adapter per customer&amp;rdquo; case for sovereign deployment.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Hu, E. et al. &lt;em>LoRA: Low-Rank Adaptation of Large Language Models&lt;/em>. ICLR 2022. &lt;a href="https://arxiv.org/abs/2106.09685">https://arxiv.org/abs/2106.09685&lt;/a>&lt;/li>
&lt;li>Sheng, Y. et al. &lt;em>S-LoRA: Serving Thousands of Concurrent LoRA Adapters&lt;/em>. MLSys 2024. &lt;a href="https://arxiv.org/abs/2311.03285">https://arxiv.org/abs/2311.03285&lt;/a>&lt;/li>
&lt;li>Chen, L. et al. &lt;em>Punica: Multi-Tenant LoRA Serving&lt;/em>. MLSys 2024. &lt;a href="https://arxiv.org/abs/2310.18547">https://arxiv.org/abs/2310.18547&lt;/a>&lt;/li>
&lt;li>Dettmers, T. et al. &lt;em>QLoRA: Efficient Finetuning of Quantized LLMs&lt;/em>. NeurIPS 2023. &lt;a href="https://arxiv.org/abs/2305.14314">https://arxiv.org/abs/2305.14314&lt;/a>&lt;/li>
&lt;li>Brüel-Gabrielsson, R. et al. &lt;em>Compress then Serve: Serving Thousands of LoRA Adapters with Little Overhead&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2407.00066">https://arxiv.org/abs/2407.00066&lt;/a>&lt;/li>
&lt;li>&lt;em>Serving Heterogeneous LoRA Adapters in Distributed LLM Inference&lt;/em>. 2025. &lt;a href="https://arxiv.org/abs/2511.22880">https://arxiv.org/abs/2511.22880&lt;/a>&lt;/li>
&lt;li>&lt;em>InfiniLoRA: Disaggregated Multi-LoRA Serving&lt;/em>. 2026. &lt;a href="https://arxiv.org/abs/2604.07173">https://arxiv.org/abs/2604.07173&lt;/a>&lt;/li>
&lt;li>&lt;em>Predictive-LoRA: Proactive Fragmentation-Aware Serverless LoRA Serving&lt;/em>. &lt;a href="https://arxiv.org/abs/2512.20210">https://arxiv.org/abs/2512.20210&lt;/a>&lt;/li>
&lt;li>S-LoRA repo: &lt;a href="https://github.com/S-LoRA/S-LoRA">https://github.com/S-LoRA/S-LoRA&lt;/a>&lt;/li>
&lt;li>Punica repo: &lt;a href="https://github.com/punica-ai/punica">https://github.com/punica-ai/punica&lt;/a>&lt;/li>
&lt;li>LoRAX repo (Predibase): &lt;a href="https://github.com/predibase/lorax">https://github.com/predibase/lorax&lt;/a>&lt;/li>
&lt;li>vLLM LoRA docs: &lt;a href="https://docs.vllm.ai/en/stable/features/lora/">https://docs.vllm.ai/en/stable/features/lora/&lt;/a>&lt;/li>
&lt;li>SGLang LoRA docs: &lt;a href="https://docs.sglang.io/advanced_features/lora.html">https://docs.sglang.io/advanced_features/lora.html&lt;/a>&lt;/li>
&lt;li>TensorRT-LLM LoRA docs: &lt;a href="https://nvidia.github.io/TensorRT-LLM/advanced/lora.html">https://nvidia.github.io/TensorRT-LLM/advanced/lora.html&lt;/a>&lt;/li>
&lt;li>NVIDIA NIM PEFT: &lt;a href="https://docs.nvidia.com/nim/large-language-models/latest/peft.html">https://docs.nvidia.com/nim/large-language-models/latest/peft.html&lt;/a>&lt;/li>
&lt;li>HF TGI Multi-LoRA blog: &lt;a href="https://huggingface.co/blog/multi-lora-serving">https://huggingface.co/blog/multi-lora-serving&lt;/a>&lt;/li>
&lt;li>LMSYS S-LoRA recipe blog: &lt;a href="https://www.lmsys.org/blog/2023-11-15-slora/">https://www.lmsys.org/blog/2023-11-15-slora/&lt;/a>&lt;/li>
&lt;li>Predibase LoRAX blog: &lt;a href="https://predibase.com/blog/lorax-the-open-source-framework-for-serving-100s-of-fine-tuned-llms-in">https://predibase.com/blog/lorax-the-open-source-framework-for-serving-100s-of-fine-tuned-llms-in&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Modern alignment: DPO, KTO, ORPO and SimPO — the sommelier who learns without a reward model</title><link>https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/</link><pubDate>Wed, 27 May 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/</guid><description>&lt;blockquote>
&lt;p>This post is the natural continuation of &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning in production&lt;/a>, which covers the operational cycle (Postgres + SQL queries + hot-swap) that feeds these methods with real data. Here we go inside each one: what each loss optimises, what hypothesis it makes, and why they choose one or the other.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Classic RLHF, the kind in the InstructGPT papers, is practically extinct in production. The reason is not ideological: it is that in 2023 Rafailov and others showed that the reward model &lt;strong>does not need to exist&lt;/strong> as a separate object. With an elegant change of variable, the optimal policy can be trained directly from preference pairs, without going through a reward and without RL. That is &lt;strong>DPO&lt;/strong>. From there the family has branched into four methods that coexist in 2026: &lt;strong>DPO&lt;/strong> when you have &lt;code>(chosen, rejected)&lt;/code> pairs, &lt;strong>KTO&lt;/strong> when you only have a binary 👍/👎 signal, &lt;strong>ORPO&lt;/strong> when you want SFT and preferences in the same pass to save memory, and &lt;strong>SimPO&lt;/strong> when you also want to get rid of the reference model and normalise by length. This post explains exactly what each loss does, proves it with an end-to-end numerical example, gives the real decision table between the four and takes apart the three biases that kill the method in production when nobody watches them.&lt;/p>
&lt;h2 id="you-are-here-tune">You are here: TUNE&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Tune">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#alm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#alm)}&lt;/style>
&lt;defs>&lt;marker id="alm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: TUNE · preference optimization without a reward model&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box active"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a> places Tune between Data and Eval. Inside Tune three modalities coexist: SFT (supervised fine-tuning), preference optimization (what this post covers) and agent training / RFT. What follows is the &lt;strong>inside&lt;/strong> of the second modality.&lt;/p>
&lt;h2 id="the-analogy-the-sommelier-who-trains-the-palate-without-a-theory-book">The analogy: the sommelier who trains the palate without a theory book&lt;/h2>
&lt;p>Imagine you want to train a sommelier. You have two routes. The first is teaching them oenological theory: grape varieties, terroirs, vinification methods, barrel types. You give them cards with canonical wine descriptions and ask them to memorise them. That is &lt;strong>SFT&lt;/strong>: &lt;code>(prompt, ideal answer)&lt;/code> pairs. It works as general education, but the sommelier who comes out of it cannot tell two excellent Riojas apart.&lt;/p>
&lt;p>The second route is the blind comparator. You put two identical opaque glasses in front of them. You say &amp;ldquo;this one is better than this one&amp;rdquo;. You do not explain why. You repeat the exercise a thousand times, with a thousand different pairs. After a while, the sommelier does not need you to say it: they have &lt;strong>a trained palate&lt;/strong>. They have not learned oenological theory, they have learned to &lt;strong>discriminate&lt;/strong>.&lt;/p>
&lt;p>DPO, KTO, ORPO and SimPO are four variants of the second route. All four train the model to discriminate, not to memorise. The differences between them are:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>DPO&lt;/strong>: two glasses at each tasting, the sommelier always knows which one is the &amp;ldquo;good&amp;rdquo; one and which is the &amp;ldquo;bad&amp;rdquo; one.&lt;/li>
&lt;li>&lt;strong>KTO&lt;/strong>: a single glass each time, they are only told &amp;ldquo;you would like this&amp;rdquo; or &amp;ldquo;you would not like this&amp;rdquo;. No pairs.&lt;/li>
&lt;li>&lt;strong>ORPO&lt;/strong>: the tasting also includes a small embedded theory class (SFT in parallel) so the catalogue is not forgotten.&lt;/li>
&lt;li>&lt;strong>SimPO&lt;/strong>: like DPO, but the sommelier does not compare against &amp;ldquo;what your master would have said&amp;rdquo; (reference model); they compare the two glasses directly, normalising by the amount of liquid in each.&lt;/li>
&lt;/ul>
&lt;p>The analogy is not decorative: the rest of the post is that same idea expressed mathematically.&lt;/p>
&lt;h2 id="why-dpo-exists-rafailovs-trick">Why DPO exists: Rafailov&amp;rsquo;s trick&lt;/h2>
&lt;p>To understand DPO it helps to walk through in thirty seconds what it replaces. Classic RLHF, the InstructGPT kind, has three phases:&lt;/p>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="classic RLHF vs DPO">
&lt;style>
.bx{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.bxa{fill:#ffe6d6;stroke:#444;stroke-width:1.4;rx:8}
.bxb{fill:#d6eaff;stroke:#444;stroke-width:1.4;rx:8}
.bxc{fill:#d9f5d6;stroke:#444;stroke-width:1.4;rx:8}
.bxx{fill:#fff5b0;stroke:#444;stroke-width:1.4;rx:8}
.lt{font:600 13px sans-serif;fill:#222}
.ls{font:400 11px sans-serif;fill:#555}
.h{font:700 13px sans-serif;fill:#222}
.ar{stroke:#666;stroke-width:1.5;fill:none;marker-end:url(#mrl)}
.aw{stroke:#c0392b;stroke-width:1.8;fill:none;marker-end:url(#mrlx)}
&lt;/style>
&lt;defs>
&lt;marker id="mrl" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>
&lt;marker id="mrlx" 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="#c0392b"/>&lt;/marker>
&lt;/defs>
&lt;text x="200" y="20" text-anchor="middle" class="h">Classic RLHF (3 phases, 3 models)&lt;/text>
&lt;rect x="40" y="35" width="160" height="55" class="bxa"/>
&lt;text x="120" y="56" text-anchor="middle" class="lt">SFT&lt;/text>
&lt;text x="120" y="74" text-anchor="middle" class="ls">π_ref ← (prompt, answer)&lt;/text>
&lt;rect x="40" y="110" width="160" height="55" class="bxb"/>
&lt;text x="120" y="131" text-anchor="middle" class="lt">Reward Model&lt;/text>
&lt;text x="120" y="149" text-anchor="middle" class="ls">r_φ ← (prompt, chosen, rejected)&lt;/text>
&lt;rect x="40" y="185" width="160" height="55" class="bxc"/>
&lt;text x="120" y="206" text-anchor="middle" class="lt">PPO (RL)&lt;/text>
&lt;text x="120" y="224" text-anchor="middle" class="ls">π_θ maximises r_φ with KL to π_ref&lt;/text>
&lt;path class="ar" d="M120,90 L120,110"/>
&lt;path class="ar" d="M120,165 L120,185"/>
&lt;text x="220" y="135" class="ls">3 models in memory&lt;/text>
&lt;text x="220" y="150" class="ls">+ on-policy rollouts&lt;/text>
&lt;text x="220" y="165" class="ls">+ unstable, hard to debug&lt;/text>
&lt;text x="560" y="20" text-anchor="middle" class="h">DPO (1 phase, 2 models)&lt;/text>
&lt;rect x="400" y="35" width="160" height="55" class="bxa"/>
&lt;text x="480" y="56" text-anchor="middle" class="lt">SFT (same)&lt;/text>
&lt;text x="480" y="74" text-anchor="middle" class="ls">π_ref ← (prompt, answer)&lt;/text>
&lt;rect x="400" y="125" width="160" height="80" class="bxx"/>
&lt;text x="480" y="146" text-anchor="middle" class="lt">DPO&lt;/text>
&lt;text x="480" y="164" text-anchor="middle" class="ls">π_θ ← (chosen, rejected)&lt;/text>
&lt;text x="480" y="180" text-anchor="middle" class="ls">closed-form loss on log-probs&lt;/text>
&lt;text x="480" y="196" text-anchor="middle" class="ls">no RL, no explicit reward&lt;/text>
&lt;path class="ar" d="M480,90 L480,125"/>
&lt;text x="580" y="220" class="ls" font-style="italic">"the reward is implicit&lt;/text>
&lt;text x="580" y="236" class="ls" font-style="italic">in log π_θ - log π_ref"&lt;/text>
&lt;path class="aw" d="M260,140 L390,140"/>
&lt;text x="325" y="130" text-anchor="middle" class="ls" fill="#c0392b">Rafailov 2023:&lt;/text>
&lt;text x="325" y="158" text-anchor="middle" class="ls" fill="#c0392b">"you only need π_θ and π_ref"&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>&lt;strong>Phase 1 – SFT.&lt;/strong> You train the model on &lt;code>(prompt, ideal answer)&lt;/code>. Out comes &lt;code>π_ref&lt;/code>: the reference policy. It is the &amp;ldquo;educated&amp;rdquo; model.&lt;/p>
&lt;p>&lt;strong>Phase 2 – Reward model.&lt;/strong> On the same model (a different head), you train a regressor: given &lt;code>(prompt, chosen, rejected)&lt;/code>, it learns to give a higher score to the &lt;code>chosen&lt;/code>. Out comes &lt;code>r_φ(x, y)&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Phase 3 – PPO.&lt;/strong> You take &lt;code>π_ref&lt;/code> as the starting point and train another copy &lt;code>π_θ&lt;/code> so that it &lt;strong>maximises &lt;code>r_φ(x, π_θ(x))&lt;/code>&lt;/strong> with a KL penalty to keep it from drifting too far from &lt;code>π_ref&lt;/code>. That requires generating rollouts (on-policy decode at every step), keeping the three models in memory (&lt;code>π_θ&lt;/code>, &lt;code>π_ref&lt;/code>, &lt;code>r_φ&lt;/code>), and a classic RL setup: unstable, sensitive to hyperparameters and famous for its reproducibility problems.&lt;/p>
&lt;p>Rafailov&amp;rsquo;s observation was: &lt;strong>phase 3 has a closed-form solution&lt;/strong>. If you write out the exact optimisation problem PPO solves&lt;/p>
$$\max_{\pi_\theta} \; \mathbb{E}_{x \sim D, \, y \sim \pi_\theta(\cdot|x)} \big[ r_\phi(x,y) \big] - \beta \, \mathrm{KL}\big(\pi_\theta(\cdot|x) \,\|\, \pi_\mathrm{ref}(\cdot|x)\big)$$
&lt;p>it turns out the optimal policy has the form&lt;/p>
$$\pi^{*}(y|x) = \frac{1}{Z(x)} \, \pi_\mathrm{ref}(y|x) \, \exp\!\left( \tfrac{1}{\beta} r_\phi(x,y) \right)$$
&lt;p>and from there you can solve for the implicit reward:&lt;/p>
$$r_\phi(x,y) = \beta \log \frac{\pi^{*}(y|x)}{\pi_\mathrm{ref}(y|x)} + \beta \log Z(x).$$
&lt;p>The second term is a function of &lt;code>x&lt;/code> only and cancels out when you compare two answers to the same prompt. &lt;strong>The reward does not need to be learned&lt;/strong>: it is implicit in the log-prob ratio between the trained model and the reference one. If you plug that into the Bradley-Terry model for preferences (the formula that says &amp;ldquo;the probability that &lt;code>yw&lt;/code> is preferred to &lt;code>yl&lt;/code> is &lt;code>σ(r(x,yw) - r(x,yl))&lt;/code>&amp;rdquo;), out comes the &lt;strong>DPO loss&lt;/strong>:&lt;/p>
$$\mathcal{L}_\mathrm{DPO}(\pi_\theta;\pi_\mathrm{ref}) = -\,\mathbb{E}_{(x, y_w, y_l) \sim D} \log \sigma\!\Big( \beta \big[ \log \tfrac{\pi_\theta(y_w|x)}{\pi_\mathrm{ref}(y_w|x)} - \log \tfrac{\pi_\theta(y_l|x)}{\pi_\mathrm{ref}(y_l|x)} \big] \Big).$$
&lt;p>That is the whole of DPO. &lt;strong>There is no reward model, no RL, no rollouts.&lt;/strong> Only log-probs over static data.&lt;/p>
&lt;h2 id="dpo-with-real-numbers">DPO with real numbers&lt;/h2>
&lt;p>The formula is less intimidating when you evaluate it with an example. Imagine a pair from the dataset:&lt;/p>
&lt;ul>
&lt;li>&lt;code>x&lt;/code> = &amp;ldquo;Explain what a KVM switch is&amp;rdquo;.&lt;/li>
&lt;li>&lt;code>y_w&lt;/code> = correct answer (chosen).&lt;/li>
&lt;li>&lt;code>y_l&lt;/code> = confusing answer (rejected).&lt;/li>
&lt;/ul>
&lt;p>After a forward pass we have four numbers (sums of per-token log-probs, negative sign because each &lt;code>log p_token ≤ 0&lt;/code>):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Quantity&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>log π_θ(y_w | x)&lt;/code>&lt;/td>
&lt;td>−45.2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>log π_θ(y_l | x)&lt;/code>&lt;/td>
&lt;td>−52.1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>log π_ref(y_w | x)&lt;/code>&lt;/td>
&lt;td>−47.3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>log π_ref(y_l | x)&lt;/code>&lt;/td>
&lt;td>−50.8&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &amp;ldquo;ratios&amp;rdquo; are the improvement of the trained model over the reference one for each answer:&lt;/p>
&lt;ul>
&lt;li>For &lt;code>y_w&lt;/code>: &lt;code>−45.2 − (−47.3) = +2.1&lt;/code> → the trained model gives it more probability than the reference one. Good.&lt;/li>
&lt;li>For &lt;code>y_l&lt;/code>: &lt;code>−52.1 − (−50.8) = −1.3&lt;/code> → the trained model gives it less probability. Also good.&lt;/li>
&lt;/ul>
&lt;p>With &lt;code>β = 0.1&lt;/code> (a typical value) the &amp;ldquo;margin&amp;rdquo; inside the logarithm is:&lt;/p>
$$m = \beta \cdot (2.1 - (-1.3)) = 0.1 \cdot 3.4 = 0.34$$
&lt;p>And the loss:&lt;/p>
$$\mathcal{L}_\mathrm{DPO} = -\log \sigma(0.34) = -\log(0.584) \approx 0.538.$$
&lt;p>The intuition is visible straight away: the more the chosen log-prob rises and the more the rejected one falls (relative to &lt;code>π_ref&lt;/code>), the more positive &lt;code>m&lt;/code> becomes, the higher the sigmoid and the lower the loss. If the margin is negative (the model gets it wrong), &lt;code>σ(m) &amp;lt; 0.5&lt;/code> and the loss blows up. The gradient pushes the model to increase &lt;code>π_θ(y_w|x)&lt;/code> and lower &lt;code>π_θ(y_l|x)&lt;/code>.&lt;/p>
&lt;p>The role of &lt;code>β&lt;/code>: it is the &lt;strong>temperature of the alignment&lt;/strong>. If &lt;code>β&lt;/code> is small, the model is allowed to drift a long way from &lt;code>π_ref&lt;/code>; if it is large, the KL weighs heavily and the model barely moves. Typical value in 2026: &lt;strong>0.05–0.3&lt;/strong>, with &lt;code>0.1&lt;/code> as a starting point.&lt;/p>
&lt;h2 id="kto-when-you-only-have-">KTO: when you only have 👍/👎&lt;/h2>
&lt;p>DPO needs &lt;strong>pairs&lt;/strong>. In practice, that is almost never what the product gives you: what the product gives you is binary feedback (a thumbs up or a thumbs down, a conversion or an abandonment). KTO — Kahneman-Tversky Optimization, Ethayarajh et al. 2024 — solves exactly that case.&lt;/p>
&lt;p>The intuition comes from Kahneman and Tversky&amp;rsquo;s &lt;strong>prospect theory&lt;/strong>: humans are &lt;strong>more sensitive to losses than to gains&lt;/strong> of the same magnitude (loss aversion: losing 100 € hurts more than gaining 100 €). KTO carries that over into the loss:&lt;/p>
$$\mathcal{L}_\mathrm{KTO}(x, y) =
\begin{cases}
\lambda_d \big[ 1 - \sigma\!\big(\beta \cdot ( h_\theta(x,y) - z_0 ) \big) \big] &amp; \text{if } y \text{ is desirable},\\
\lambda_u \big[ 1 - \sigma\!\big(\beta \cdot ( z_0 - h_\theta(x,y) ) \big) \big] &amp; \text{if } y \text{ is undesirable},
\end{cases}$$
&lt;p>where &lt;code>h_θ(x,y) = log(π_θ(y|x) / π_ref(y|x))&lt;/code> is exactly the same ratio that appeared in DPO, &lt;code>z_0&lt;/code> is an estimate of the batch KL divergence (it acts as a &amp;ldquo;neutral point&amp;rdquo;) and &lt;code>λ_d&lt;/code>, &lt;code>λ_u&lt;/code> are the desirable / undesirable weights.&lt;/p>
&lt;p>The critical point: &lt;strong>KTO does not need pairs&lt;/strong>. Each example is &lt;code>(prompt, answer, binary label)&lt;/code>. This fits real product telemetry. The usual practical rule is &lt;code>λ_u &amp;gt; λ_d&lt;/code> (e.g. 1.0 vs 0.33) when the database has more 👍 than 👎, so that the negative signal is not diluted.&lt;/p>
&lt;p>KTO works particularly well in two scenarios:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Products with explicit feedback UX&lt;/strong> (chatbots with 👍/👎): each thumbs is a direct KTO example, no need to synthesise pairs.&lt;/li>
&lt;li>&lt;strong>Imbalanced datasets&lt;/strong> (far more 👍 than 👎, or the other way round): the weights &lt;code>λ_d&lt;/code>, &lt;code>λ_u&lt;/code> handle it explicitly.&lt;/li>
&lt;/ol>
&lt;h2 id="orpo-sft-and-preferences-in-a-single-pass">ORPO: SFT and preferences in a single pass&lt;/h2>
&lt;p>DPO assumes you have already done SFT. You train two phases: first SFT to get &lt;code>π_ref&lt;/code>, then DPO on top of &lt;code>π_ref&lt;/code>. Two passes over the data, two optimisations, two models in memory.&lt;/p>
&lt;p>ORPO — Odds Ratio Preference Optimization, Hong et al. 2024 — &lt;strong>merges both phases&lt;/strong>. The loss combines two terms:&lt;/p>
$$\mathcal{L}_\mathrm{ORPO} = \mathcal{L}_\mathrm{SFT}(y_w) + \lambda \cdot \mathcal{L}_\mathrm{OR}(y_w, y_l)$$
&lt;p>The first term is classic SFT on the chosen answer (negative cross-entropy). The second is the &lt;strong>odds ratio&lt;/strong> between chosen and rejected:&lt;/p>
$$\mathcal{L}_\mathrm{OR} = -\log \sigma\!\Big( \log \tfrac{\mathrm{odds}_\theta(y_w|x)}{\mathrm{odds}_\theta(y_l|x)} \Big), \quad \text{with } \mathrm{odds}_\theta(y|x) = \tfrac{P_\theta(y|x)}{1 - P_\theta(y|x)}.$$
&lt;p>What matters: &lt;strong>there is no &lt;code>π_ref&lt;/code>&lt;/strong>. ORPO trains a single model, in a single pass, without loading the reference policy into memory. On paper it sounds good and in practice it works: a Llama 3 8B aligned with ORPO over 5k pairs takes about 3 hours on 4×H100 and fits in VRAM with aggressive QLoRA on a single RTX 4090.&lt;/p>
&lt;p>The &lt;code>λ&lt;/code> parameter is the weight of the preference term. Typical: &lt;code>0.1–0.3&lt;/code>. If &lt;code>λ&lt;/code> is too high, the model learns to discriminate but forgets the SFT (catastrophic forgetting); if it is too low, the alignment barely shows.&lt;/p>
&lt;h2 id="simpo-do-you-really-need-a-reference-model">SimPO: do you really need a reference model?&lt;/h2>
&lt;p>SimPO — Simple Preference Optimization, Meng et al. 2024 — takes ORPO&amp;rsquo;s question one step further: if ORPO frees itself from &lt;code>π_ref&lt;/code> for the combined SFT+preference case, &lt;strong>why not free yourself from &lt;code>π_ref&lt;/code> in the pure DPO case too?&lt;/strong>&lt;/p>
&lt;p>The SimPO loss:&lt;/p>
$$\mathcal{L}_\mathrm{SimPO} = -\log \sigma\!\Big( \tfrac{\beta}{|y_w|} \log \pi_\theta(y_w|x) - \tfrac{\beta}{|y_l|} \log \pi_\theta(y_l|x) - \gamma \Big).$$
&lt;p>Two changes with respect to DPO:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>There is no &lt;code>π_ref&lt;/code>&lt;/strong>: absolute log-probs of the trained model are compared directly.&lt;/li>
&lt;li>&lt;strong>Length-normalization&lt;/strong>: each log-prob is divided by the length of its answer &lt;code>|y|&lt;/code>. This is key because without normalising, long answers tend to have a lower total log-prob (each token contributes its &lt;code>log p &amp;lt; 0&lt;/code>), creating an artificial bias.&lt;/li>
&lt;li>&lt;strong>Explicit margin &lt;code>γ&lt;/code>&lt;/strong>: the loss is low if the difference of normalised log-probs exceeds &lt;code>γ&lt;/code>. Typical: &lt;code>γ = 0.5–1.5&lt;/code>.&lt;/li>
&lt;/ol>
&lt;p>With &lt;code>β = 2.0&lt;/code>, &lt;code>γ = 1.0&lt;/code>, &lt;code>|y_w| = 120&lt;/code> tokens, &lt;code>|y_l| = 100&lt;/code> tokens and the earlier log-probs:&lt;/p>
$$m = \tfrac{2.0}{120} \cdot (-45.2) - \tfrac{2.0}{100} \cdot (-52.1) - 1.0 = -0.753 + 1.042 - 1.0 = -0.711.$$
$$\mathcal{L}_\mathrm{SimPO} = -\log \sigma(-0.711) \approx 1.11.$$
&lt;p>A higher loss than DPO at the same point: SimPO takes longer to converge, but uses half the memory (a single model in VRAM) and removes the dependency on &lt;code>π_ref&lt;/code>. It is the dominant option when memory is the bottleneck.&lt;/p>
&lt;h2 id="decision-table-which-one-to-use-and-when">Decision table: which one to use and when&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Signal available&lt;/th>
&lt;th>Memory available&lt;/th>
&lt;th>Prior SFT&lt;/th>
&lt;th>Recommended method&lt;/th>
&lt;th>Rationale&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>(chosen, rejected)&lt;/code> pairs&lt;/td>
&lt;td>High (≥ 80 GB GPU)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;strong>DPO&lt;/strong>&lt;/td>
&lt;td>Most established baseline, better reproducibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>(chosen, rejected)&lt;/code> pairs&lt;/td>
&lt;td>Low (24–48 GB GPU)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;strong>SimPO&lt;/strong>&lt;/td>
&lt;td>Removes &lt;code>π_ref&lt;/code> → ~50 % less VRAM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>(chosen, rejected)&lt;/code> pairs&lt;/td>
&lt;td>Any&lt;/td>
&lt;td>No&lt;/td>
&lt;td>&lt;strong>ORPO&lt;/strong>&lt;/td>
&lt;td>SFT and preferences in one pass&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Binary 👍/👎 signal without pairs&lt;/td>
&lt;td>High&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;strong>KTO&lt;/strong>&lt;/td>
&lt;td>The only method native to unpaired data&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Binary signal + few pairs&lt;/td>
&lt;td>High&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;strong>KTO&lt;/strong> with a DPO sub-batch&lt;/td>
&lt;td>Combination documented in TRL 0.13&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multistep trajectories (tool use)&lt;/td>
&lt;td>Very high&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;strong>Pure RLHF/RFT&lt;/strong>&lt;/td>
&lt;td>Preference-pair methods do not capture the dynamics&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Typical dataset magnitudes:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Minimum viable&lt;/th>
&lt;th>Sweet spot&lt;/th>
&lt;th>Plateau&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>DPO&lt;/td>
&lt;td>1,000 pairs&lt;/td>
&lt;td>5,000–20,000&lt;/td>
&lt;td>&amp;gt; 50,000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SimPO&lt;/td>
&lt;td>2,000 pairs&lt;/td>
&lt;td>5,000–20,000&lt;/td>
&lt;td>&amp;gt; 50,000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ORPO&lt;/td>
&lt;td>3,000 pairs (includes SFT)&lt;/td>
&lt;td>10,000–30,000&lt;/td>
&lt;td>&amp;gt; 80,000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>KTO&lt;/td>
&lt;td>5,000 binary examples&lt;/td>
&lt;td>20,000–80,000&lt;/td>
&lt;td>&amp;gt; 200,000&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>KTO typically needs &lt;strong>3-4× more volume&lt;/strong> than DPO because the binary signal is weaker than the comparative one. The trade-off is that the binary signal is the one a production product naturally produces.&lt;/p>
&lt;h2 id="the-three-biases-that-break-the-method">The three biases that break the method&lt;/h2>
&lt;p>All four methods share one problem: they are training on &lt;strong>proxies&lt;/strong> for quality, not on quality. Those proxies have systematic biases the model can exploit trivially.&lt;/p>
&lt;h3 id="length-bias">Length bias&lt;/h3>
&lt;p>Documented in the original DPO paper and in the literature that followed. Long answers tend to be preferred by humans, probably because they look &amp;ldquo;more complete&amp;rdquo;. If the pair dataset inherits that bias, the model learns that &lt;strong>lengthening the answer is what gets rewarded&lt;/strong>, not that better content is what gets rewarded. Result: after 2–3 epochs the model spews waffle.&lt;/p>
&lt;p>Mitigations:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>DPO&lt;/strong>: filter the dataset, removing pairs where &lt;code>|y_w| &amp;gt; 1.3 · |y_l|&lt;/code> (the 30 % rule).&lt;/li>
&lt;li>&lt;strong>SimPO&lt;/strong>: the length-normalization in the loss fixes it by construction.&lt;/li>
&lt;li>&lt;strong>ORPO / KTO&lt;/strong>: dataset filtering or an auxiliary length regularisation (DPOP, R-DPO).&lt;/li>
&lt;/ul>
&lt;div class="diagram" style="max-width:740px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 740 230" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Length bias during training">
&lt;style>
.ax{stroke:#444;stroke-width:1.4;fill:none}
.gr{stroke:#888;stroke-width:0.5;fill:none;stroke-dasharray:2 2}
.cv1{stroke:#c0392b;stroke-width:2.5;fill:none}
.cv2{stroke:#2980b9;stroke-width:2.5;fill:none}
.lb{font:600 11px sans-serif;fill:#222}
.sm{font:400 10px sans-serif;fill:#555}
.h{font:700 13px sans-serif;fill:#222}
&lt;/style>
&lt;text x="370" y="20" text-anchor="middle" class="h">Length bias: mean answer length during training&lt;/text>
&lt;line x1="60" y1="180" x2="700" y2="180" class="ax"/>
&lt;line x1="60" y1="180" x2="60" y2="50" class="ax"/>
&lt;line x1="60" y1="150" x2="700" y2="150" class="gr"/>
&lt;line x1="60" y1="120" x2="700" y2="120" class="gr"/>
&lt;line x1="60" y1="90" x2="700" y2="90" class="gr"/>
&lt;line x1="60" y1="60" x2="700" y2="60" class="gr"/>
&lt;text x="55" y="184" text-anchor="end" class="sm">100&lt;/text>
&lt;text x="55" y="154" text-anchor="end" class="sm">150&lt;/text>
&lt;text x="55" y="124" text-anchor="end" class="sm">200&lt;/text>
&lt;text x="55" y="94" text-anchor="end" class="sm">250&lt;/text>
&lt;text x="55" y="64" text-anchor="end" class="sm">300&lt;/text>
&lt;text x="20" y="115" class="sm" transform="rotate(-90,20,115)">tokens / answer&lt;/text>
&lt;text x="370" y="210" text-anchor="middle" class="sm">epoch&lt;/text>
&lt;text x="100" y="200" text-anchor="middle" class="sm">0&lt;/text>
&lt;text x="220" y="200" text-anchor="middle" class="sm">1&lt;/text>
&lt;text x="340" y="200" text-anchor="middle" class="sm">2&lt;/text>
&lt;text x="460" y="200" text-anchor="middle" class="sm">3&lt;/text>
&lt;text x="580" y="200" text-anchor="middle" class="sm">4&lt;/text>
&lt;text x="680" y="200" text-anchor="middle" class="sm">5&lt;/text>
&lt;path class="cv1" d="M100,150 C160,140 200,120 220,110 C280,80 340,65 460,55 C540,52 620,51 680,50"/>
&lt;path class="cv2" d="M100,150 C160,148 220,146 340,144 C460,143 580,142 680,142"/>
&lt;rect x="430" y="60" width="170" height="40" fill="white" stroke="#bbb"/>
&lt;line x1="438" y1="72" x2="468" y2="72" class="cv1"/>
&lt;text x="475" y="76" class="lb">DPO without filter&lt;/text>
&lt;line x1="438" y1="90" x2="468" y2="90" class="cv2"/>
&lt;text x="475" y="94" class="lb">SimPO (length-norm)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The chart shows the usual pattern: with no mitigation, in 3 epochs a Llama 3 8B with DPO can go from answers of about 150 tokens to answers of about 280 tokens, &lt;strong>without any improvement in human-evaluated quality&lt;/strong>. SimPO keeps the length roughly stable.&lt;/p>
&lt;h3 id="position-bias-in-dataset-curation">Position bias (in dataset curation)&lt;/h3>
&lt;p>If the pairs are generated automatically with an LLM judge (covered in detail in the next post of this series), there is a known bias: &lt;strong>judges prefer the first answer&lt;/strong> they see when the two are comparable. If every pair in the dataset always has the chosen in position A and the rejected in position B, the model does not learn preference: it learns an artefact of the curation process.&lt;/p>
&lt;p>Mitigation: random shuffling of the order in the query to the judge &lt;strong>and averaging two passes with the order reversed&lt;/strong> (seen in tools such as Promptfoo and Inspect AI by default).&lt;/p>
&lt;h3 id="distribution-shift-between-the-data-and-π_ref">Distribution shift between the data and &lt;code>π_ref&lt;/code>&lt;/h3>
&lt;p>DPO, KTO and SimPO implicitly compare the trained model against a distribution. If the preference data comes from a model (another LLM generating candidates) very different from &lt;code>π_ref&lt;/code>, the trained model can explore regions where &lt;code>π_ref&lt;/code> has almost zero probability, giving numerically unstable ratios (the log of very small quantities). In practice this shows up as loss explosions, NaN gradients or silent regression.&lt;/p>
&lt;p>Mitigation: &lt;strong>generate the dataset candidates with &lt;code>π_ref&lt;/code> itself&lt;/strong> whenever possible (rejection sampling over &lt;code>π_ref&lt;/code>, with an external judge picking the chosen). That is the canonical on-policy RLHF prescription applied to the offline setting.&lt;/p>
&lt;h2 id="implications-on-on-premise-hardware">Implications on on-premise hardware&lt;/h2>
&lt;p>The figures below are indicative for a typical May 2026 scenario: Llama 3.1 8B Instruct as &lt;code>π_ref&lt;/code>, a dataset of 5,000–20,000 pairs, QLoRA (NF4) with LoRA rank 16 over all the transformer block projectors (&lt;code>q,k,v,o,gate,up,down&lt;/code>), effective batch size 16, 1–3 epochs.&lt;/p>
&lt;h3 id="on-an-rtx-4090-24-gb">On an RTX 4090 (24 GB)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Peak VRAM&lt;/th>
&lt;th>Time / epoch (5k pairs)&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>DPO&lt;/td>
&lt;td>~22 GB&lt;/td>
&lt;td>50–80 min&lt;/td>
&lt;td>Needs &lt;code>π_ref&lt;/code> in VRAM even if quantised FP8/INT8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SimPO&lt;/td>
&lt;td>~14 GB&lt;/td>
&lt;td>45–70 min&lt;/td>
&lt;td>No &lt;code>π_ref&lt;/code>, the natural option on a 4090&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ORPO&lt;/td>
&lt;td>~16 GB&lt;/td>
&lt;td>60–90 min&lt;/td>
&lt;td>No &lt;code>π_ref&lt;/code>, comfortably viable&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>KTO&lt;/td>
&lt;td>~22 GB&lt;/td>
&lt;td>90–150 min (10k binary)&lt;/td>
&lt;td>Same VRAM as DPO, more data per epoch&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The 4090 (24 GB, Ada Lovelace, no NVLink) is &lt;strong>perfectly viable&lt;/strong> for Llama 8B with QLoRA if you pick the method sensibly. For 13B the choice between SimPO/ORPO is no longer a preference, it is a requirement.&lt;/p>
&lt;h3 id="on-a-generic-4h100-sxm-cluster-320-gb-nvlink">On a generic 4×H100 SXM cluster (320 GB, NVLink)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Viable model&lt;/th>
&lt;th>Time / epoch (10k pairs)&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>DPO&lt;/td>
&lt;td>Llama 3 70B (4-bit)&lt;/td>
&lt;td>60–90 min&lt;/td>
&lt;td>Tensor parallel = 4, still comfortable&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SimPO&lt;/td>
&lt;td>Llama 3 70B (BF16)&lt;/td>
&lt;td>50–75 min&lt;/td>
&lt;td>Full BF16 fits thanks to having no &lt;code>π_ref&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ORPO&lt;/td>
&lt;td>Llama 3 70B (BF16)&lt;/td>
&lt;td>70–100 min&lt;/td>
&lt;td>Similar to SimPO in consumption&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>KTO&lt;/td>
&lt;td>Llama 3 70B (4-bit)&lt;/td>
&lt;td>100–140 min&lt;/td>
&lt;td>Larger datasets offset by parallelism&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>On an NVLink cluster the operational difference between methods blurs: they all fit. The choice goes back to &lt;strong>what kind of signal you have&lt;/strong>, not to budget.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>LoRA and QLoRA fundamentals&lt;/strong>: the maths of the low-rank adapter that underpins everything above, and why a 70B fits in 24 GB.&lt;/li>
&lt;li>&lt;strong>LLM-as-judge fundamentals&lt;/strong>: how to build the judge that generates the chosen/rejected pairs without position or verbosity bias. The next post in this batch covers it.&lt;/li>
&lt;li>&lt;strong>Online DPO and iterative on-policy&lt;/strong>: the 2026 research state of the art (Fast-Slow Chasing, RLOO, iterative preference learning) and why it is not production yet.&lt;/li>
&lt;li>&lt;strong>Distillation and synthetic preference data&lt;/strong>: when it is worth generating pairs with a large model to train a small one.&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/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — the operational cycle (Postgres, SQL queries, multi-LoRA hot-swap) that feeds pairs to the methods in this post.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop&lt;/a> — how production signals become the preference dataset.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs: the layer after tracing&lt;/a> — the battery of evaluators that decides whether the aligned adapter is promoted to production.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge: the exam marker&lt;/a> — the mechanism that generates the &lt;code>(chosen, rejected)&lt;/code> pairs consumed here. Judge calibration with Cohen&amp;rsquo;s kappa and the four biases that invalidate the pairs if nobody watches them.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving: the single translator with a thousand glossaries&lt;/a> — each alignment policy (DPO with dataset A, KTO with dataset B, ORPO with dataset C) can live as a separate adapter and be served in parallel: real production A/B without deploying three copies of the base.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Rafailov, R., Sharma, A., Mitchell, E., Ermon, S., Manning, C. D., Finn, C. &lt;em>Direct Preference Optimization: Your Language Model is Secretly a Reward Model&lt;/em> (NeurIPS 2023).&lt;/li>
&lt;li>Ethayarajh, K., Xu, W., Muennighoff, N., Jurafsky, D., Kiela, D. &lt;em>KTO: Model Alignment as Prospect Theoretic Optimization&lt;/em> (ICML 2024).&lt;/li>
&lt;li>Hong, J., Lee, N., Thorne, J. &lt;em>ORPO: Monolithic Preference Optimization without Reference Model&lt;/em> (EMNLP 2024).&lt;/li>
&lt;li>Meng, Y., Xia, M., Chen, D. &lt;em>SimPO: Simple Preference Optimization with a Reference-Free Reward&lt;/em> (NeurIPS 2024).&lt;/li>
&lt;li>HuggingFace TRL 0.13 — reference implementations: &lt;a href="https://huggingface.co/docs/trl">https://huggingface.co/docs/trl&lt;/a>.&lt;/li>
&lt;li>Tunstall, L. et al. &lt;em>The Alignment Handbook&lt;/em> — reproducible recipes: &lt;a href="https://github.com/huggingface/alignment-handbook">https://github.com/huggingface/alignment-handbook&lt;/a>.&lt;/li>
&lt;li>Park, R. et al. &lt;em>Disentangling Length from Quality in Direct Preference Optimization&lt;/em> (R-DPO, ACL 2024).&lt;/li>
&lt;/ul></description></item><item><title>Continuous fine-tuning in production: from real traffic to the deployed adapter</title><link>https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/</link><pubDate>Thu, 21 May 2026 10:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Continuous fine-tuning is not &amp;ldquo;training the model every so often&amp;rdquo;. It is a &lt;strong>closed loop&lt;/strong> where real production traffic generates the datasets, a short pipeline trains a LoRA adapter, a battery of evaluations decides whether it is promoted, and vLLM loads it &lt;strong>without restarting&lt;/strong>. The state of the art in May 2026 has fragmented the stack: it is no longer DPO against everything, but a choice between SFT, DPO, KTO, ORPO and SimPO according to the type of signal your product captures. What has consolidated the pattern is the combination of PostgreSQL 18 + pgvector 0.8 as the &lt;strong>nervous system of the pipeline&lt;/strong>, traffic capture, dataset versioning, eval results, adapter registry, together with vLLM multi-LoRA hot-swap, which turns deployment into an HTTP call. This article takes the cycle apart with concrete schemas, real queries, and the numbers it costs on an RTX 4090 against a 4×H100 cluster.&lt;/p>
&lt;h2 id="you-are-here-tune--retrain">You are here: Tune + Retrain&lt;/h2>
&lt;p>This post crosses two stages 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>: the decision to &lt;strong>train a new adapter&lt;/strong> (the &lt;strong>Tune&lt;/strong> stage) is triggered by the signals from &lt;strong>Observe&lt;/strong> that travel through the &lt;strong>Retrain&lt;/strong> stage until the loop closes. The post takes apart the complete circuit between the two boxes.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Tune + Retrain">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:3}.active2{fill:#fff5b0;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#ftm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#ftm)}&lt;/style>
&lt;defs>&lt;marker id="ftm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: TUNE + RETRAIN · continuous adapter cycle driven by real traffic&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box active"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box active2"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-restaurant-that-tunes-its-menu">The analogy: the restaurant that tunes its menu&lt;/h2>
&lt;p>Picture a neighbourhood restaurant with a signature dish that works, but the chef knows it can be tuned. Every night things happen:&lt;/p>
&lt;ul>
&lt;li>Some diners &lt;strong>leave part of the dish&lt;/strong>: a weak signal that something did not quite land.&lt;/li>
&lt;li>Others ask for &lt;strong>another version&lt;/strong> (&amp;ldquo;could you put less salt in it?&amp;rdquo;): an explicit, directional signal.&lt;/li>
&lt;li>Others &lt;strong>finish the dish and come back the following week&lt;/strong>: the only signal that really matters, but it arrives late.&lt;/li>
&lt;li>And a select group offers an opinion without being asked, usually a negative one.&lt;/li>
&lt;/ul>
&lt;p>The chef does not redo the menu every night. He does something more interesting: he writes down in a notebook the dishes served, the returns, the changes requested, the tips. Every so often, he &lt;strong>reads the whole notebook&lt;/strong>, decides on minimal adjustments to a recipe, tries the new version at a private table with his staff, and only if they take to it does he add it to the next day&amp;rsquo;s menu. Sometimes he even serves two different versions of the dish to different tables for a week, measures what happens, and picks one.&lt;/p>
&lt;p>That is &lt;strong>continuous fine-tuning&lt;/strong>. The notebook is Postgres. The dish is the base model. The notes are feedback signals, explicit and implicit. The &amp;ldquo;minimal adjustment&amp;rdquo; is a 30 MB LoRA adapter. The private table is the battery of automated evaluations. The next day&amp;rsquo;s menu is vLLM with multi-LoRA hot-swap, which loads the new adapter without restarting the service. Serving two versions to different tables is A/B testing with real traffic.&lt;/p>
&lt;p>The analogy is exact on one critical point: &lt;strong>the chef does not throw away the original recipe&lt;/strong>. He keeps the base recipe and holds a separate notebook with the &amp;ldquo;modifications that work well for the neighbourhood regulars&amp;rdquo;. That notebook is the LoRA adapter: on top of the base model, not in its place.&lt;/p>
&lt;h2 id="the-cycle-taken-apart">The cycle, taken apart&lt;/h2>
&lt;p>Before going into components, it helps to fix the complete flow. These seven steps are what any serious team replicates with variations:&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Closed loop of continuous fine-tuning">
&lt;style>
.node { fill: #f4f4f4; stroke: #333; stroke-width: 1.5; }
.n-serve { fill: #d6eaff; }
.n-data { fill: #ffe9d6; }
.n-train { fill: #d9f5d6; }
.n-eval { fill: #f4e1ff; }
.lbl { font: 600 12px sans-serif; fill: #222; }
.lbl-sm { font: 10.5px sans-serif; fill: #444; }
.arr { stroke: #444; stroke-width: 1.4; fill: none; marker-end: url(#ah2); }
.arr-dim { stroke: #888; stroke-width: 1.2; fill: none; stroke-dasharray: 4,3; marker-end: url(#ah2); }
&lt;/style>
&lt;defs>
&lt;marker id="ah2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
&lt;path d="M0,0 L10,5 L0,10 z" fill="#444"/>
&lt;/marker>
&lt;/defs>
&lt;text x="360" y="22" text-anchor="middle" class="lbl">The closed loop of continuous fine-tuning&lt;/text>
&lt;rect x="280" y="40" width="160" height="50" rx="8" class="node n-serve"/>
&lt;text x="360" y="62" text-anchor="middle" class="lbl">1 · vLLM serving&lt;/text>
&lt;text x="360" y="78" text-anchor="middle" class="lbl-sm">base + active adapters&lt;/text>
&lt;rect x="500" y="120" width="170" height="50" rx="8" class="node n-data"/>
&lt;text x="585" y="142" text-anchor="middle" class="lbl">2 · Traffic capture&lt;/text>
&lt;text x="585" y="158" text-anchor="middle" class="lbl-sm">prompts, responses, feedback&lt;/text>
&lt;rect x="500" y="210" width="170" height="50" rx="8" class="node n-data"/>
&lt;text x="585" y="232" text-anchor="middle" class="lbl">3 · Curation&lt;/text>
&lt;text x="585" y="248" text-anchor="middle" class="lbl-sm">dedup, PII, balancing, snapshot&lt;/text>
&lt;rect x="280" y="290" width="160" height="50" rx="8" class="node n-train"/>
&lt;text x="360" y="312" text-anchor="middle" class="lbl">4 · LoRA training&lt;/text>
&lt;text x="360" y="328" text-anchor="middle" class="lbl-sm">SFT / DPO / KTO / ORPO / SimPO&lt;/text>
&lt;rect x="50" y="210" width="170" height="50" rx="8" class="node n-eval"/>
&lt;text x="135" y="232" text-anchor="middle" class="lbl">5 · Eval gates&lt;/text>
&lt;text x="135" y="248" text-anchor="middle" class="lbl-sm">3 stages: PR, full, canary&lt;/text>
&lt;rect x="50" y="120" width="170" height="50" rx="8" class="node n-train"/>
&lt;text x="135" y="142" text-anchor="middle" class="lbl">6 · Adapter registry&lt;/text>
&lt;text x="135" y="158" text-anchor="middle" class="lbl-sm">status: canary | prod | retired&lt;/text>
&lt;rect x="50" y="40" width="170" height="50" rx="8" class="node n-serve"/>
&lt;text x="135" y="62" text-anchor="middle" class="lbl">7 · Hot-swap&lt;/text>
&lt;text x="135" y="78" text-anchor="middle" class="lbl-sm">POST /v1/load_lora_adapter&lt;/text>
&lt;path class="arr" d="M440,75 C480,80 495,100 510,120"/>
&lt;path class="arr" d="M585,170 L585,210"/>
&lt;path class="arr" d="M500,250 C460,270 440,280 440,300"/>
&lt;path class="arr" d="M280,315 C240,290 230,275 220,260"/>
&lt;path class="arr" d="M135,210 L135,170"/>
&lt;path class="arr" d="M135,120 L135,90"/>
&lt;path class="arr" d="M220,65 L280,65"/>
&lt;rect x="280" y="170" width="160" height="80" rx="10" fill="#fffae6" stroke="#d4a52a" stroke-width="2"/>
&lt;text x="360" y="200" text-anchor="middle" class="lbl">PostgreSQL 18&lt;/text>
&lt;text x="360" y="218" text-anchor="middle" class="lbl-sm">+ pgvector 0.8&lt;/text>
&lt;text x="360" y="234" text-anchor="middle" class="lbl-sm">single source of truth&lt;/text>
&lt;path class="arr-dim" d="M500,145 L440,180"/>
&lt;path class="arr-dim" d="M500,235 L440,225"/>
&lt;path class="arr-dim" d="M280,225 L220,235"/>
&lt;path class="arr-dim" d="M280,200 L220,160"/>
&lt;path class="arr-dim" d="M360,290 L360,250"/>
&lt;/svg>
&lt;/div>
&lt;p>The cycle lasts between 1 and 4 weeks in real production. What changes between teams is the tempo (faster in assistant chat, slower in regulated banking) and the details of each step. The structure is the same.&lt;/p>
&lt;h2 id="why-continuous-fine-tuning-and-why-it-is-not-rag">Why continuous fine-tuning (and why it is not RAG)&lt;/h2>
&lt;p>Before going deeper, a distinction that people still confuse. &lt;strong>Fine-tuning is for form, not for facts.&lt;/strong> If your problem is that the model does not know the customer&amp;rsquo;s rates or the updated catalogue, do not fine-tune: use RAG. If your problem is that the model responds in a tone that does not fit, does not respect your JSON format, refuses legitimate cases or invents structure, then fine-tuning is the answer.&lt;/p>
&lt;p>In 2026 the boundary is well established by community practice:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Observed problem&lt;/th>
&lt;th>Solution&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>The model does not know X (X changes weekly)&lt;/td>
&lt;td>RAG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model knows X but responds badly in tone or format&lt;/td>
&lt;td>SFT fine-tuning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>There are two ways of responding and I prefer one over the other&lt;/td>
&lt;td>Fine-tuning with preferences (DPO/KTO/ORPO/SimPO)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model reasons badly in a verifiable domain (code, maths)&lt;/td>
&lt;td>RL with verifiable reward (GRPO/DAPO)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model is competent, it just needs a memory of facts&lt;/td>
&lt;td>RAG, not fine-tuning&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Continuous fine-tuning is the disciplined version of the second and third cases. The key word is &lt;strong>continuous&lt;/strong>: it is not a one-off &amp;ldquo;we aligned the model&amp;rdquo; event, it is a process that runs every time the traffic distribution drifts far enough, or new use cases appear.&lt;/p>
&lt;h2 id="the-four-techniques-according-to-the-signal-you-capture">The four techniques according to the signal you capture&lt;/h2>
&lt;p>The most important change of the last 12 months has been the end of DPO&amp;rsquo;s monopoly. In 2024 every team doing alignment used DPO with &lt;code>(chosen, rejected)&lt;/code> pairs. In 2026 the choice is finer and depends on &lt;strong>what the signal you collect in your product looks like&lt;/strong>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Real signal in the product&lt;/th>
&lt;th>Recommended technique&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Labelled correct examples (input → expected output)&lt;/td>
&lt;td>&lt;strong>SFT + LoRA&lt;/strong>&lt;/td>
&lt;td>Still the baseline. 500-5,000 examples are enough for style.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Explicit &lt;code>(chosen, rejected)&lt;/code> pairs&lt;/td>
&lt;td>&lt;strong>DPO&lt;/strong> or &lt;strong>SimPO&lt;/strong>&lt;/td>
&lt;td>SimPO removes the reference model → 50 % less VRAM in training.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Loose 👍 / 👎 on responses&lt;/td>
&lt;td>&lt;strong>KTO&lt;/strong>&lt;/td>
&lt;td>The method that fits real telemetry most naturally.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SFT and preferences in a single pass&lt;/td>
&lt;td>&lt;strong>ORPO&lt;/strong>&lt;/td>
&lt;td>A single model in memory, avoids drift between phases.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Verifiable reward (tests, solutions)&lt;/td>
&lt;td>&lt;strong>GRPO&lt;/strong> / &lt;strong>DAPO&lt;/strong>&lt;/td>
&lt;td>Reasoning, not chat. A different world.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The practical rule: &lt;strong>design the feedback capture in the product thinking about which method you will be able to use afterwards&lt;/strong>. If your UI only has 👍/👎, you force the path to KTO. If you add a &amp;ldquo;regenerate response&amp;rdquo; button, you unlock DPO from regenerate-as-rejected (we will see it below). If you add an &amp;ldquo;edit response&amp;rdquo; button, the edited response becomes high-quality direct SFT.&lt;/p>
&lt;p>There is a cost detail that gets little publicity. DPO needs to keep &lt;strong>two models&lt;/strong> in memory: the one you are training and the reference one. SimPO removes that second model. ORPO does too. For a Llama 3 8B in BF16 this is the difference between needing ~32 GB of active VRAM during training (DPO) or ~16 GB (SimPO/ORPO). It is the difference between the training fitting on an RTX 4090 with aggressive QLoRA, or not fitting without offload.&lt;/p>
&lt;h2 id="postgres-as-the-nervous-system-of-the-pipeline">Postgres as the nervous system of the pipeline&lt;/h2>
&lt;p>Here is the strong technical opinion of this article, and it is one worth defending with data: &lt;strong>Postgres 18 + pgvector 0.8 + an S3/MinIO bucket for the weights is enough for the whole pipeline&lt;/strong>. You do not need MLflow, you do not need lakeFS, you do not need DVC.&lt;/p>
&lt;p>This is not ideological minimalism. It is about three concrete advantages that no alternative stack matches in the on-premise scenario with compliance:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>A single source of truth, a single authorisation model.&lt;/strong> The ACLs you already have for Postgres cover the training data, the eval results, the adapter registry and the audit log. You do not multiply control planes.&lt;/li>
&lt;li>&lt;strong>SQL as the pipeline&amp;rsquo;s universal language.&lt;/strong> The query that generates the dataset, the eval gate predicate, the A/B traffic assignment, the promotion decision: it is all SQL. Your team already knows SQL.&lt;/li>
&lt;li>&lt;strong>Audit and cryptographic reproducibility for free.&lt;/strong> The &lt;code>pg_audit&lt;/code> and &lt;code>pgcrypto&lt;/code> extensions, combined with &lt;code>set_hash&lt;/code> over the dataset, give you cryptographic traceability with no additional code. It is a subject that would fill an article of its own.&lt;/li>
&lt;/ol>
&lt;h3 id="a-concrete-schema">A concrete schema&lt;/h3>
&lt;p>We start with the traffic table, partitioned by week so that &lt;code>DROP PARTITION&lt;/code> is cheap:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">BIGSERIAL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">request_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">user_hash&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">BYTEA&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- GDPR pseudonymisation
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- e.g. &amp;#34;support-es-v4.1&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">experiment&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- e.g. &amp;#34;rerank-v2-canary&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">variant&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">CHAR&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#39;A&amp;#39; | &amp;#39;B&amp;#39; | NULL
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">JSONB&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">completion&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">ttft_ms&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">tokens_in&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">tokens_out&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">-- feedback signals
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">SMALLINT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- -1/0/+1 (KTO-ready)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_regen&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BOOLEAN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">false&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- user regenerated -&amp;gt; DPO-rejected
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_edited&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BOOLEAN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">false&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- user edited -&amp;gt; SFT golden
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">parent_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BIGINT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- self-reference for regenerate
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- vector and meta
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">HALFVEC&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1024&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- pgvector 0.8, half the RAM
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pii_flags&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">SMALLINT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- bitmask
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMPTZ&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">RANGE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log_2026w21&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">OF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;2026-05-18&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;2026-05-25&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log_2026w21&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">hnsw&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">halfvec_cosine_ops&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log_2026w21&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&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>Three decisions deserve a note:&lt;/p>
&lt;p>&lt;strong>&lt;code>HALFVEC(1024)&lt;/code>.&lt;/strong> Native FP16 vectors in pgvector 0.8. Half the RAM and disk, with a loss of precision that is irrelevant for semantic deduplication. This alone, at a scale of millions of rows, saves between 4 and 8 GB.&lt;/p>
&lt;p>&lt;strong>Weekly partitioning by time range.&lt;/strong> At 90 days, &lt;code>DROP TABLE obs.inference_log_2026wXX&lt;/code> frees space in milliseconds without a prolonged lock. Autovacuum never touches frozen partitions again.&lt;/p>
&lt;p>&lt;strong>Self-referencing &lt;code>parent_id&lt;/code>.&lt;/strong> The user regenerates the response → a new row is inserted with &lt;code>parent_id&lt;/code> pointing at the previous one. That will give us a DPO dataset without touching the UX.&lt;/p>
&lt;h3 id="the-adapter-registry">The adapter registry&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">serve&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">base_model&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">rank&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">alpha&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">target_modules&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">JSONB&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">method&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#39;sft&amp;#39;|&amp;#39;dpo&amp;#39;|&amp;#39;kto&amp;#39;|&amp;#39;orpo&amp;#39;|&amp;#39;simpo&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">training_run_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">dataset_snapshot_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">weights_uri&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- s3://.../v4.2.safetensors
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">eval_summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">JSONB&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">status&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#39;training&amp;#39;|&amp;#39;canary&amp;#39;|&amp;#39;prod&amp;#39;|&amp;#39;retired&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">traffic_pct&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">NUMERIC&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">promoted_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMPTZ&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The vLLM router reads this table with a TTL of a few seconds. An &lt;code>UPDATE serve.adapter SET status='prod', traffic_pct=100 WHERE id='v4.2'&lt;/code> is a promotion. An &lt;code>UPDATE ... SET status='retired'&lt;/code> is a rollback. The audit of who did what and when comes from &lt;code>pg_audit&lt;/code> without writing a single additional line of code.&lt;/p>
&lt;h2 id="generating-dpo-and-kto-datasets-from-real-traffic">Generating DPO and KTO datasets from real traffic&lt;/h2>
&lt;p>This is where the elegance of the schema pays off. The dataset is not a static file: it is a &lt;strong>materialised view&lt;/strong> built with SQL over &lt;code>obs.inference_log&lt;/code>.&lt;/p>
&lt;h3 id="kto-dataset-from-">KTO dataset from 👍/👎&lt;/h3>
&lt;p>KTO is the method that best fits the signal any decent chat product captures. The query:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">MATERIALIZED&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VIEW&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">kto_v3_candidate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">completion&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">response&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">CASE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">THEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ELSE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">false&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">END&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">label&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">!=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">interval&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;60 days&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pii_flags&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">tenant&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">consent_training&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>Simple. Every row with explicit feedback becomes an example &lt;code>(prompt, response, desirable_yes_no)&lt;/code>. KTO trains directly on this signal, with no need to build pairs.&lt;/p>
&lt;h3 id="dpo-dataset-from-regenerate">DPO dataset from &amp;ldquo;regenerate&amp;rdquo;&lt;/h3>
&lt;p>The trick that is worth this article on its own. When the user presses &amp;ldquo;regenerate response&amp;rdquo;, they are giving an extraordinarily strong signal: the first response was no good to them. If the second is neither regenerated nor rated negatively, we assume it was. That is a DPO pair without a single extra click in the UI:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">MATERIALIZED&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VIEW&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">dpo_v3_candidate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">cho&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">completion&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chosen&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">completion&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rejected&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">cho&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">cho&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">parent_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">fb_regen&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">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="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">cho&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">-- length bias mitigation in classic DPO
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">cho&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tokens_out&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BETWEEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tokens_out&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tokens_out&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The clause on lengths is the cheap cure for the &lt;strong>length bias&lt;/strong> documented in DPO. Without it, the model learns that &amp;ldquo;longer = better&amp;rdquo; because the responses the user accepts tend to be slightly longer. With SimPO or ORPO this filter is optional; with classic DPO it is necessary.&lt;/p>
&lt;h3 id="semantic-deduplication-with-pgvector">Semantic deduplication with pgvector&lt;/h3>
&lt;p>Before training, dedup. Two almost identical prompts in the dataset are noise that biases the model:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ranked&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">row_number&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">OVER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">hashtext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="p">::&lt;/span>&lt;span class="nb">text&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rn&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">interval&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;60 days&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">DELETE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">kto_v3_candidate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">kto&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ranked&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">rn&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">kto&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And for the semantic duplicates (paraphrases) we use pgvector 0.8 directly with an &lt;code>iterative index scan&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- Find near-duplicates of any given example
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;=&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dist&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">interval&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;60 days&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;=&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">05&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;=&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">50&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>iterative scan&lt;/code> is a key improvement in pgvector 0.8: before, the HNSW index could return fewer results than requested when there were additional filters (&lt;code>WHERE&lt;/code>); now it iterates until it meets the limit. Without that improvement, curation queries over datasets of millions of rows were unworkable without a brutal pre-filter.&lt;/p>
&lt;h2 id="eval-gates-three-stages-all-sql">Eval gates: three stages, all SQL&lt;/h2>
&lt;p>The most common mistake when implementing continuous fine-tuning is skipping or watering down the eval gates. That turns the cycle into a roulette wheel. The pattern that works in 2026 is &lt;strong>three stages&lt;/strong>, each with a different latency/coverage trade-off:&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three stages of eval gates">
&lt;style>
.stage { stroke: #333; stroke-width: 1.5; }
.s1 { fill: #d6eaff; }
.s2 { fill: #d9f5d6; }
.s3 { fill: #ffe9d6; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm { font: 11px sans-serif; fill: #444; }
.arr { stroke: #444; stroke-width: 1.6; fill: none; marker-end: url(#ah3); }
&lt;/style>
&lt;defs>
&lt;marker id="ah3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
&lt;path d="M0,0 L10,5 L0,10 z" fill="#444"/>
&lt;/marker>
&lt;/defs>
&lt;text x="360" y="22" text-anchor="middle" class="lbl">Three stages of eval gates&lt;/text>
&lt;rect x="30" y="50" width="200" height="110" rx="10" class="stage s1"/>
&lt;text x="130" y="78" text-anchor="middle" class="lbl">Stage 1 · PR&lt;/text>
&lt;text x="130" y="98" text-anchor="middle" class="lbl-sm">&amp;lt; 90 seconds&lt;/text>
&lt;text x="130" y="118" text-anchor="middle" class="lbl-sm">schema-lint + prompt-lint&lt;/text>
&lt;text x="130" y="135" text-anchor="middle" class="lbl-sm">+ 50 mini-eval cases&lt;/text>
&lt;rect x="260" y="50" width="200" height="110" rx="10" class="stage s2"/>
&lt;text x="360" y="78" text-anchor="middle" class="lbl">Stage 2 · pre-merge&lt;/text>
&lt;text x="360" y="98" text-anchor="middle" class="lbl-sm">&amp;lt; 20 minutes&lt;/text>
&lt;text x="360" y="118" text-anchor="middle" class="lbl-sm">200-500 golden cases&lt;/text>
&lt;text x="360" y="135" text-anchor="middle" class="lbl-sm">+ LLM-as-judge&lt;/text>
&lt;rect x="490" y="50" width="200" height="110" rx="10" class="stage s3"/>
&lt;text x="590" y="78" text-anchor="middle" class="lbl">Stage 3 · canary&lt;/text>
&lt;text x="590" y="98" text-anchor="middle" class="lbl-sm">24-72 hours&lt;/text>
&lt;text x="590" y="118" text-anchor="middle" class="lbl-sm">1-5 % real traffic&lt;/text>
&lt;text x="590" y="135" text-anchor="middle" class="lbl-sm">online metrics + feedback&lt;/text>
&lt;path class="arr" d="M230,105 L260,105"/>
&lt;path class="arr" d="M460,105 L490,105"/>
&lt;/svg>
&lt;/div>
&lt;p>And this is where Postgres shines again: the promotion gate is expressed as an SQL predicate. Nothing more:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">eval_result&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REFERENCES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">serve&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">suite_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#39;safety-es&amp;#39;, &amp;#39;support-helpfulness&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">metric&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">NUMERIC&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">judge_model&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">judged_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMPTZ&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">(),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">suite_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">metric&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">OR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REPLACE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FUNCTION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">serve&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">can_promote&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">candidate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">current&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">RETURNS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BOOLEAN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$$&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">eval_result&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">eval_result&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">suite_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">metric&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">candidate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">current&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">suite_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;safety-es&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;support-helpfulness&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;refusal-rate&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">98&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 2 % tolerance
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="err">$$&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LANGUAGE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">sql&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">STABLE&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>An SQL function as the gate. Usable from CI with &lt;code>psql -c &amp;quot;SELECT serve.can_promote('v4.2','v4.1')&amp;quot;&lt;/code> and an exit code of 0/1. No orchestrator needed, no specific UI needed. The audit stays in the Postgres log.&lt;/p>
&lt;h2 id="vllm-multi-lora-the-deploy-is-an-http-post">vLLM multi-LoRA: the deploy is an HTTP POST&lt;/h2>
&lt;p>Two years ago, deploying a new fine-tune meant rotating inference pods. Today it is an HTTP call. vLLM 0.7+ supports loading and unloading LoRA adapters &lt;strong>hot&lt;/strong>, keeping several resident in VRAM and choosing the right one per request.&lt;/p>
&lt;p>Server configuration:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Llama-3.1-8B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-lora &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-loras &lt;span class="m">4&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-lora-rank &lt;span class="m">64&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --env &lt;span class="nv">VLLM_ALLOW_RUNTIME_LORA_UPDATING&lt;/span>&lt;span class="o">=&lt;/span>True
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Deploying a new adapter:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -X POST http://localhost:8000/v1/load_lora_adapter &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;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;lora_name&amp;#34;: &amp;#34;support-es-v4.2&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;lora_path&amp;#34;: &amp;#34;/mnt/adapters/support-es-v4.2&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From that moment on, requests including &lt;code>&amp;quot;model&amp;quot;: &amp;quot;support-es-v4.2&amp;quot;&lt;/code> are served with that adapter applied on top of the base model. Switching between adapters has negligible latency (the most recent research on Activated LoRA takes this to levels where the switching cost is invisible).&lt;/p>
&lt;p>This changes operations substantially. &lt;strong>Deploying a new fine-tune stops being an infrastructure event and becomes a state change in Postgres&lt;/strong>. The router queries the &lt;code>serve.adapter&lt;/code> table, sees that &lt;code>v4.2&lt;/code> is in &lt;code>canary&lt;/code> with &lt;code>traffic_pct=5&lt;/code>, and directs 5 % of requests to the new adapter. The exact route of that 5 % is decided with deterministic hashing of the &lt;code>user_id&lt;/code> so that the same user always sees the same variant (sticky):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- No assignment table, no additional state
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- the variant is computed in-place in SQL or in the router:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">CASE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">hashtext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">||&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="n">experiment&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">%&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">traffic_pct&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">serve&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="n">candidate&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">THEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="n">candidate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ELSE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="k">current&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">END&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="ab-with-real-traffic-measure-or-live-deceived">A/B with real traffic: measure or live deceived&lt;/h2>
&lt;p>The eval gates measure against fixed benchmarks. That is necessary but insufficient. Reality is only measured with real traffic. Once the adapter is in canary, what matters are the &lt;strong>online metrics&lt;/strong> measured over &lt;code>obs.inference_log&lt;/code> for each variant:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AVG&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">mean_score&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">STDDEV&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">SQRT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">sem&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AVG&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ttft_ms&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ttft_avg&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">percentile_cont&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">WITHIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ttft_ms&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ttft_p50&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">percentile_cont&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">95&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">WITHIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ttft_ms&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ttft_p95&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AVG&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">CASE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_regen&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">THEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ELSE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">END&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">regen_rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">experiment&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">interval&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;7 days&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What to look at: explicit feedback, latency (TTFT, p50, p95), regeneration rate. An adapter that raises the mean feedback but also raises the regeneration rate is suspicious, probably it is responding in a flashier but less useful way. An adapter that lowers latency but lowers feedback may be worth studying: it may be being more concise than it should.&lt;/p>
&lt;p>Promotion to &lt;code>prod&lt;/code> happens when, after 24-72 hours in canary, the candidate adapter beats the current one on at least one key metric without degrading the others. Again: it is an &lt;code>UPDATE&lt;/code> in Postgres.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>Let us come down to two representative configurations, one for iteration and one for production.&lt;/p>
&lt;h3 id="case-1--rtx-4090-24-gb-for-development-iteration">Case 1 — RTX 4090 (24 GB) for development iteration&lt;/h3>
&lt;p>An RTX 4090 with 4-bit QLoRA can train adapters on an 8B model without trouble. The VRAM budget combines four components; the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> during the intermediate evaluations is not negligible and deserves an explicit margin:&lt;/p>
&lt;pre tabindex="0">&lt;code>8B base model in 4-bit: ~5 GB
Activations + gradients: ~8 GB (depends on batch and context)
Optimizer state (LoRA r=16): ~0.5 GB
KV cache during eval: ~2 GB
Safety margin: ~8 GB
&lt;/code>&lt;/pre>&lt;p>Typical times (an estimate based on community benchmarks; measure in the lab before promising anything):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dataset&lt;/th>
&lt;th>Technique&lt;/th>
&lt;th style="text-align:right">Adapter rank&lt;/th>
&lt;th style="text-align:right">Approximate time&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1,000 SFT examples&lt;/td>
&lt;td>LoRA r=16&lt;/td>
&lt;td style="text-align:right">16&lt;/td>
&lt;td style="text-align:right">20-40 min&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5,000 SFT examples&lt;/td>
&lt;td>LoRA r=32&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">2-4 h&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2,000 DPO pairs&lt;/td>
&lt;td>LoRA r=16&lt;/td>
&lt;td style="text-align:right">16&lt;/td>
&lt;td style="text-align:right">1-2 h&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5,000 KTO examples&lt;/td>
&lt;td>LoRA r=32&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">3-5 h&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>This puts the iteration cycle, a change in the dataset, retrain, eval, look at the number, in the range of a &lt;strong>working day&lt;/strong>. Enough to validate a hypothesis before moving anything to the production cluster.&lt;/p>
&lt;h3 id="case-2--4h100-sxm-cluster-320-gb-nvlink-for-production">Case 2 — 4×H100 SXM cluster (320 GB, NVLink) for production&lt;/h3>
&lt;p>With a cluster of this order the whole picture changes. You can:&lt;/p>
&lt;ul>
&lt;li>Train &lt;strong>LoRA on a 70B in BF16 with no quantisation&lt;/strong> with tensor parallel = 4.&lt;/li>
&lt;li>Do &lt;strong>full DPO with a resident reference model&lt;/strong> when the reference is quantised to FP8, or move to &lt;strong>SimPO / ORPO&lt;/strong>, which remove that intermediate model and simplify VRAM planning (see the table of techniques above).&lt;/li>
&lt;li>Support &lt;strong>multi-tenant fine-tuning&lt;/strong>: several customer adapters training in parallel in separate logical pipelines, each isolated in a different Postgres partition with its own ACLs.&lt;/li>
&lt;li>Serve &lt;strong>multi-LoRA with &lt;code>--max-loras 8&lt;/code>&lt;/strong> on top of the base model without concurrency lowering throughput perceptibly.&lt;/li>
&lt;/ul>
&lt;p>The practical budgeting rule: over a 12-month horizon, a team with this cluster can run &lt;strong>~150-200 continuous fine-tuning cycles&lt;/strong> (training + eval + canary + promotion or discard) if the discipline around the dataset and the eval gates is strict. If it is not, it will run twice as many but with half the usefulness.&lt;/p>
&lt;h2 id="position-within-the-architecture-what-this-article-covers-and-what-it-does-not">Position within the architecture: what this article covers and what it does not&lt;/h2>
&lt;p>To place the scope: the cycle drawn at the start has seven boxes, all of them covered here in their mechanics. Three cross-cutting layers are &lt;strong>deliberately left out&lt;/strong>, and they are the ones that end up separating a pipeline that works technically from one that survives an audit:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Cryptographic provenance and traceability.&lt;/strong> We have mentioned &lt;code>dataset_snapshot&lt;/code> and &lt;code>pg_audit&lt;/code>, but the complete mechanics, the &lt;code>set_hash&lt;/code> over the examples, the integration with the EU AI Act, the frozen &lt;code>query_sql&lt;/code> as proof of what trained the model, is enough for a whole analysis.&lt;/li>
&lt;li>&lt;strong>Judge calibration.&lt;/strong> We have assumed LLM-as-judge works. It needs calibrating against a human rubric on at least 100 cases per critical suite before trusting it. Without that calibration, the eval gates are theatre.&lt;/li>
&lt;li>&lt;strong>The forgetting problem.&lt;/strong> What happens if a user exercises their GDPR right to be forgotten and their interactions were part of the dataset of an adapter already in production? There is no clean solution. There are options, incremental retrain, sample-level machine unlearning, negative LoRA, and it is worth knowing them before a customer asks.&lt;/li>
&lt;/ol>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Cryptographic provenance on Postgres&lt;/strong>: how &lt;code>set_hash&lt;/code> and a frozen &lt;code>query_sql&lt;/code> compose an auditable chain of custody under the EU AI Act.&lt;/li>
&lt;li>&lt;strong>Honest judge calibration&lt;/strong>: why &lt;code>score &amp;gt; 0.85&lt;/code> means nothing without a human baseline, and how to build that baseline without it costing a month of work.&lt;/li>
&lt;li>&lt;strong>The forgetting problem in adapters&lt;/strong>: sample-level machine unlearning, incremental retrain and other techniques for answering GDPR without throwing the adapter away.&lt;/li>
&lt;li>&lt;strong>Online DPO and on-policy continual learning&lt;/strong>: the state of 2026 research (Fast-Slow Chasing, RLOO, iterative on-policy) and why it is not production yet.&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 the Tune and Retrain stages belong to. This post is the operational deep-dive of that cycle.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">LLM-specific MLOps in 2026: the landscape&lt;/a> — the opening of the series. It places continuous fine-tuning within the framework of three modalities (fine-tuning, RAG, agents) and seven structural differences from classic MLOps.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in the ingestion stage&lt;/a> — the Data stage that feeds this cycle: how events enter Postgres and embeddings enter Qdrant, which this post would then curate as a dataset.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> — the Eval stage of the pipeline. The eval gates described here as SQL predicates are the materialisation of that post&amp;rsquo;s generic framework.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">The GPU cluster as a multi-tenant platform&lt;/a> — the Deploy stage where the multi-LoRA hot-swap described here coexists with quotas, gateway and isolation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a> — the fundamentals of the cache that comes into play in every intermediate eval of the training and in every deployment of the resulting adapter.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention from the inside&lt;/a> — the mechanics of the KV cache and the landscape of optimisations (vAttention, EvicPress, RadixAttention) that hold up the throughput of the intermediate evals in the fine-tuning pipeline.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode in specialised pods&lt;/a> — the serving pattern the multi-LoRA hot-swap described here connects to: each specialised pod loads its adapters separately.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning for LLMOps: DVC, lakeFS and a reproducible golden dataset&lt;/a> — the opposite position. This post defends a minimalist stack (Postgres + pgvector + S3) with no DVC/lakeFS; the other explains when the line is crossed and why.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO and SimPO&lt;/a> — the mathematical foundations behind each of the preference optimisation methods this post uses operationally. Derivation of DPO from RLHF, a numerical example, biases.&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 that evaluates other models&lt;/a> — the mechanism that generates the &lt;code>(chosen, rejected)&lt;/code> pairs that then feed DPO/SimPO/ORPO in this cycle.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference: FP8, INT4 and GGUF&lt;/a> — the QLoRA with NF4 that holds up the training on an RTX 4090 is the quantisation family seen in detail there. And the DPO reference model is quantised with the same formats to free VRAM.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving: the single translator with a thousand glossaries&lt;/a> — the other side of the loop. This post trains the adapters; multi-LoRA serving (SGMV, S-LoRA, vLLM/LoRAX) is what puts hundreds of them to work concurrently against a single base without replicating the base.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Hu et al., &lt;em>LoRA: Low-Rank Adaptation of Large Language Models&lt;/em> (ICLR 2022).&lt;/li>
&lt;li>Dettmers et al., &lt;em>QLoRA: Efficient Finetuning of Quantized LLMs&lt;/em> (NeurIPS 2023).&lt;/li>
&lt;li>Rafailov et al., &lt;em>Direct Preference Optimization: Your Language Model is Secretly a Reward Model&lt;/em> (NeurIPS 2023).&lt;/li>
&lt;li>Meng, Xia, Chen, &lt;em>SimPO: Simple Preference Optimization with a Reference-Free Reward&lt;/em> (NeurIPS 2024).&lt;/li>
&lt;li>Hong et al., &lt;em>ORPO: Monolithic Preference Optimization without Reference Model&lt;/em> (2024).&lt;/li>
&lt;li>Ethayarajh et al., &lt;em>KTO: Model Alignment as Prospect Theoretic Optimization&lt;/em> (2024).&lt;/li>
&lt;li>Kwon et al., &lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em> (SOSP 2023) — original vLLM.&lt;/li>
&lt;li>Official vLLM Multi-LoRA documentation: &lt;a href="https://docs.vllm.ai/en/stable/features/lora/">https://docs.vllm.ai/en/stable/features/lora/&lt;/a>.&lt;/li>
&lt;li>Official pgvector 0.8 documentation: &lt;a href="https://github.com/pgvector/pgvector">https://github.com/pgvector/pgvector&lt;/a>.&lt;/li>
&lt;li>TRL (HuggingFace) docs: &lt;a href="https://huggingface.co/docs/trl">https://huggingface.co/docs/trl&lt;/a>.&lt;/li>
&lt;li>EU AI Act, consolidated text and application timeline: &lt;a href="https://artificialintelligenceact.eu/">https://artificialintelligenceact.eu/&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>MLOps specific to LLMs in 2026: the landscape of three modalities, six stages and ten tools that make them work</title><link>https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/</link><pubDate>Thu, 21 May 2026 05:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>This is the blog&amp;rsquo;s fourth series and it is called &lt;strong>MLOps specific to LLMs&lt;/strong>. It takes the operational craft of traditional MLOps (reproducible pipelines, model registries, dataset versioning, eval gates, controlled deployments) and redraws it for a world where the model is probabilistic, the outputs are subjective, the dependencies include external vendors that update weights without warning, and the &amp;ldquo;application&amp;rdquo; is not a model but an orchestration of models, embeddings, retrievers, guardrails and routers. Gartner predicts that more than 50% of enterprise GenAI deployments will fail before the end of 2026, and the main cause is not the model: it is that deterministic software assumptions were applied to probabilistic systems. This post opens the series with the frame: the seven structural differences between LLMOps and classic MLOps; the six-stage pipeline (data → tune → eval → deploy → observe → retrain); the three modalities of preparing a model (continuous fine-tuning, RAG over datalakes, agent training) with their decision matrix, where 60% of 2025-2026 deployments use hybrid because each modality solves a different problem: &amp;ldquo;fine-tune for behaviour, RAG for volatile knowledge&amp;rdquo;; and the 2026 tool landscape, which already forms reasonably stable layers: MLflow 3.10 (March 2026) as a GenAI-aware registry, W&amp;amp;B Weave and ZenML for tracing and pipelines, Kubeflow + KServe vLLM 0.8.1+ for serving, BentoML for flexibility, DVC + lakeFS (joined since November 2025) for data, Langfuse for prompts and observability. The three following posts will go down into the detail of the most critical pieces.&lt;/p>
&lt;blockquote>
&lt;p>This is the opening of series 4: MLOps for LLMs. It continues the tradition of the previous series: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">LLM inference&lt;/a> (the first), &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF&lt;/a> (the second) and &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">post-tracing&lt;/a> (the third). Here we enter the discipline that ties all the pieces together: how an LLM system is operated in production over months, not just deployed once.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-the-sres-craft-redrawn">The analogy: the SRE&amp;rsquo;s craft redrawn&lt;/h2>
&lt;p>Anyone who has worked for years as an SRE or a platform engineer recognises the classic pillars: reproducibility (same code + same data + same config = same result), observability (what happens can be measured), safe rollback (if something goes wrong, I go back within minutes), gradual rollout (the new thing goes to 1% before it goes to 100%). These pillars are not negotiable. The question is whether they hold up when the central component is an LLM.&lt;/p>
&lt;p>The answer is: &lt;strong>same pillars, radically different mechanics&lt;/strong>. Reproducibility: versioning code and data is no longer enough; you have to version prompts, retrieval configurations, snapshots of the vendor&amp;rsquo;s model (which change without warning). Observability: error and latency metrics are no longer enough; you have to measure subjective quality via LLM-as-judge and embedding drift. Rollback: dropping the binary&amp;rsquo;s version is no longer enough; you have to keep the old model cached because loading a new one takes minutes. Gradual rollout: a % of traffic is no longer enough; you have to decide what % of what kind of queries by segment.&lt;/p>
&lt;p>It is the same craft, practised with partly new tools and reflexes. MLOps specific to LLMs, or &amp;ldquo;LLMOps&amp;rdquo;, as the field has christened itself, is the discipline that codifies those reflexes.&lt;/p>
&lt;h2 id="the-seven-structural-differences-between-llmops-and-traditional-mlops">The seven structural differences between LLMOps and traditional MLOps&lt;/h2>
&lt;p>Before going down to the pipeline, let us fix the differences that make this territory new rather than a mere continuation. Each one has concrete practical consequences.&lt;/p>
&lt;h3 id="1-non-deterministic-outputs">1. Non-deterministic outputs&lt;/h3>
&lt;p>Traditional MLOps: the model receives structured input and returns a bounded, reproducible prediction. Same input → same output. Unit tests work.&lt;/p>
&lt;p>LLMOps: same input → different output every time (through sampling, through temperature, through the order of tools invoked, through the retrieval context that changed). The idea of a &amp;ldquo;unit test&amp;rdquo; breaks.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: tests over properties (was the tone maintained?, does it mention the source?, does it respect the JSON schema?), not over equality. Statistical evals over a distribution, not over samples.&lt;/p>
&lt;h3 id="2-behaviour-metrics-not-statistical-accuracy">2. Behaviour metrics, not statistical accuracy&lt;/h3>
&lt;p>Traditional MLOps: F1, accuracy, AUC, RMSE. Metrics with a clear number.&lt;/p>
&lt;p>LLMOps: subjective rubric scores (G-Eval, faithfulness, helpfulness, toxicity), judge LLMs, human feedback. The &amp;ldquo;number&amp;rdquo; depends on who judges.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: platforms have to treat evals as versioned artifacts, not just &amp;ldquo;model v3 scored 0.87&amp;rdquo;, but &amp;ldquo;model v3 evaluated with the judge claude-3-5-sonnet-20251022 over the dataset gold-rag-v7 with the prompt judge-v2 scored 0.87&amp;rdquo;. Versioning the judge is as important as versioning the model being evaluated.&lt;/p>
&lt;h3 id="3-the-model-is-an-external-dependency-not-an-internal-asset">3. The model is an external dependency, not an internal asset&lt;/h3>
&lt;p>Traditional MLOps: you train the model, it lives in your registry, it does not change until you change it.&lt;/p>
&lt;p>LLMOps: the base model belongs to Anthropic, OpenAI, Google, Meta. They change it on you without warning. The &lt;code>claude-3-5-sonnet&lt;/code> version that answered well yesterday answers somewhat differently today.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: drift detection becomes far more critical (&lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">previous post&lt;/a>). Pinning to specific snapshots (&lt;code>claude-3-5-sonnet-20251022&lt;/code>) when the vendor allows it. For high-commitment apps, self-hosting the base model to guarantee reproducibility.&lt;/p>
&lt;h3 id="4-the-application-is-an-orchestration-not-a-model">4. The application is an orchestration, not a model&lt;/h3>
&lt;p>Traditional MLOps: an app calls a model and consumes its output.&lt;/p>
&lt;p>LLMOps 2026: an app connects foundation model + LoRA adapters + retrievers + vector stores + guardrails + routers + tool servers (MCP) + evaluators, all composing the final behaviour. Any component can degrade the result.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: cross-component debugging requires distributed tracing with OTel (covered in previous posts). The registry does not only store &amp;ldquo;the model&amp;rdquo; but the composition: which prompt version + which adapter + which vector store + which retriever config.&lt;/p>
&lt;h3 id="5-cost-per-inference-not-per-training">5. Cost per inference, not per training&lt;/h3>
&lt;p>Traditional MLOps: the high cost is training; serving is cheap. You optimise training.&lt;/p>
&lt;p>LLMOps: the high cost is serving (every token costs, every call to the vendor is paid for, the GPUs doing the serving are switched on 24/7). You optimise inference.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: cost accountability per tenant, per agent, per tool. Metrics such as &lt;code>gen_ai.usage.input_tokens&lt;/code> aggregated at customer and product level. Model decisions based on cost per query, not only on quality.&lt;/p>
&lt;h3 id="6-gpu-heavy-infra-with-specific-primitives">6. GPU-heavy infra with specific primitives&lt;/h3>
&lt;p>Traditional MLOps: CPU + some GPU for training. Standard Kubernetes.&lt;/p>
&lt;p>LLMOps: Hopper/Blackwell SXM GPUs, NVLink/NVSwitch, tensor parallel, paged attention, KV cache. Infra that only fits into Kubernetes with primitives such as LeaderWorkerSet, GPU Operator, KEDA with LLM metrics (covered in &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>).&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: the orchestration stack includes specialised operators (OME, vLLM Production Stack, NVIDIA Dynamo, llm-d, see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a>) that traditional MLOps does not contemplate.&lt;/p>
&lt;h3 id="7-rlhf-and-human-feedback-as-a-first-class-citizen">7. RLHF and human feedback as a first-class citizen&lt;/h3>
&lt;p>Traditional MLOps: human feedback means labelling data before training.&lt;/p>
&lt;p>LLMOps: human feedback lives inside the model in production, whether through RLHF of the foundation models (Anthropic, OpenAI), through RLAIF, through DPO, or through explicit user feedback that is fed back into fine-tuning.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: bidirectional production → training pipelines. Datasets grow with real incidents. Model decisions are taken with continuous feedback, not in a training project every N months.&lt;/p>
&lt;h2 id="why-gartner-predicts-50-failures">Why Gartner predicts 50%+ failures&lt;/h2>
&lt;p>&lt;a href="https://medium.com/@sanjeebmeister/the-complete-mlops-llmops-roadmap-for-2026-building-production-grade-ai-systems-bdcca5ed2771">Gartner published that more than 50% of enterprise GenAI deployments will fail before 2026&lt;/a>. The causes are not technical ones about the model but about the system:&lt;/p>
&lt;ul>
&lt;li>Hallucinated outputs from poor grounding: badly designed RAG, poor retrieval, insufficient context.&lt;/li>
&lt;li>Data architectures that are not ready: companies have data in silos, with no stable schemas, with no controlled freshness. Connecting an LLM to this data without a serious pipeline produces erratic answers.&lt;/li>
&lt;li>Lack of structured workflows for prompt-driven systems: teams that treat prompts as code in hardcoded strings, with no versioning, no tests, no gates.&lt;/li>
&lt;/ul>
&lt;p>The conclusion the field draws: &lt;strong>LLMOps is not optional&lt;/strong>. Companies that deploy GenAI without operational discipline fall into one of the three failure modes. Those that apply it (MLflow/W&amp;amp;B for tracking, DVC/lakeFS for data, Langfuse for prompts and evals, KServe or vLLM Production Stack for serving, drift detection in production) are the ones that keep the system working six months after the first release.&lt;/p>
&lt;h2 id="the-six-stage-llmops-pipeline">The six-stage LLMOps pipeline&lt;/h2>
&lt;p>On to the pipeline. The six stages that any serious LLM system goes through, in order:&lt;/p>
&lt;pre tabindex="0">&lt;code>[1. Data] → [2. Tune] → [3. Eval] → [4. Deploy] → [5. Observe] → [6. Retrain]
│
└─→ back to 1
&lt;/code>&lt;/pre>&lt;p>Each stage is an operational domain of its own, with its own tools and traps:&lt;/p>
&lt;h3 id="stage-1--data">Stage 1 — Data&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: ingestion, cleaning, curation, versioning, indexing of the corpus. It is where real projects suffer most, because companies have data in heterogeneous silos.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: extraction from source (CDC over Kafka, batch from data lakes, scraping), cleaning (PII removal, dedup, format), curation (labelling for fine-tuning, golden datasets for eval), versioning (DVC + lakeFS), indexing (embeddings + vector store for RAG).&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: schema drift at the source, undetected PII, poor dedup that injects redundancy into training, a vector store that is not updated.&lt;/p>
&lt;h3 id="stage-2--tune">Stage 2 — Tune&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: preparing the model for your use case. Three modalities (we go deeper into them shortly): fine-tuning, RAG, agent training.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: base model selection, adapter preparation (LoRA, QLoRA), training loop with continuous eval, hyperparameter sweep (Optuna, W&amp;amp;B Sweeps), saving the checkpoint.&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: catastrophic forgetting if the fine-tuning is very aggressive, overfitting to the golden dataset, no independent validation set.&lt;/p>
&lt;h3 id="stage-3--eval">Stage 3 — Eval&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: validating that the model + adapters + RAG configuration is acceptable before promoting. Covered in &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: running an eval framework (DeepEval, Promptfoo, Ragas) against the golden dataset, judge LLM evaluations, human review over a sample, gates with thresholds.&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: a golden dataset that ages, an uncalibrated judge, evals that pass in CI but fail in production because of distribution shift.&lt;/p>
&lt;h3 id="stage-4--deploy">Stage 4 — Deploy&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: going from &amp;ldquo;the model evaluated well&amp;rdquo; to &amp;ldquo;the model serves real traffic&amp;rdquo;. Covered in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: serving with vLLM/SGLang/TRT-LLM, runtime configuration, gradual rollout (canary, shadow, blue-green), routing between models (LiteLLM, OpenRouter, LangChain routers).&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: a naive rolling update that cuts sessions, autoscaling by CPU% that does not respond to LLM metrics (covered), a new model that performs worse in production than in eval.&lt;/p>
&lt;h3 id="stage-5--observe">Stage 5 — Observe&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: seeing what is happening in real time. Covered in the whole post-tracing series and the eBPF series.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: tracing (Langfuse, LangSmith, Phoenix, OpenLLMetry), metrics (TTFT, TPOT, queue depth, cost per query), active guardrails (NeMo, Llama Guard), drift detection (Evidently, NannyML, WhyLabs).&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: cardinality explosion in metrics, batch evals with no tail-sampling over real traces, drift that is ignored until an incident makes it real.&lt;/p>
&lt;h3 id="stage-6--retrain">Stage 6 — Retrain&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: closing the loop. Production feedback (incidents, worst-scoring cases, detected drift) generates new data to go back to stage 1.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: extraction of problematic logs, human labelling of the sample, incorporation into the golden dataset, re-fine-tuning if applicable, decision on a new release.&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: an &amp;ldquo;open&amp;rdquo; loop where production never informs the dataset, human feedback that gets lost, lack of a clear retrain cadence.&lt;/p>
&lt;h2 id="the-three-modalities-of-preparing-the-model">The three modalities of &amp;ldquo;preparing the model&amp;rdquo;&lt;/h2>
&lt;p>Stage 2 (Tune) is where there is most confusion. In 2026 three modalities coexist, each solving a different problem:&lt;/p>
&lt;h3 id="fine-tuning">Fine-tuning&lt;/h3>
&lt;p>&lt;strong>What it does&lt;/strong>: modifying the model&amp;rsquo;s weights (or those of a LoRA/QLoRA adapter on top) so that it learns specific behaviour patterns: tone, output structure, idiomatic decisions of the domain.&lt;/p>
&lt;p>&lt;strong>When&lt;/strong>: when your main failure is behavioural inconsistency between calls. The model sometimes answers formally, sometimes not; sometimes it structures the JSON, sometimes not; sometimes it follows the company&amp;rsquo;s conventions, sometimes it invents them. Fine-tuning stabilises it.&lt;/p>
&lt;p>&lt;strong>When NOT&lt;/strong>: when what you need is up-to-date knowledge. Fine-tuning fixes knowledge in frozen weights; the day after the fine-tuning, the model knows nothing new.&lt;/p>
&lt;h3 id="rag-retrieval-augmented-generation">RAG (Retrieval-Augmented Generation)&lt;/h3>
&lt;p>&lt;strong>What it does&lt;/strong>: leaving the model intact and, on each call, retrieving fresh context from a knowledge base (vector store + lexical search, typically) and passing it to the model so that it answers on that basis.&lt;/p>
&lt;p>&lt;strong>When&lt;/strong>: when the knowledge you need is dynamic or very large. Documentation that changes, a product catalogue that is updated, an internal knowledge base that grows.&lt;/p>
&lt;p>&lt;strong>When NOT&lt;/strong>: when the problem is behavioural (RAG does not teach the model how to behave, it only gives it information). Or when retrieval is so noisy that the context arriving is worse than nothing.&lt;/p>
&lt;h3 id="agent-training">Agent training&lt;/h3>
&lt;p>&lt;strong>What it does&lt;/strong>: going beyond conventional fine-tuning with Reinforcement Learning techniques. RFT (OpenAI&amp;rsquo;s Reinforcement Fine-Tuning), classic RLHF, RLAIF (with AI feedback), DPO (Direct Preference Optimization) over datasets of (good, bad) pairs.&lt;/p>
&lt;p>&lt;strong>When&lt;/strong>: when the model needs to learn complex multistep trajectories: when to choose each tool, how to decompose a task, when to ask the user for confirmation. It is what is turning Claude, Gemini and GPT into agents capable of long tasks.&lt;/p>
&lt;p>&lt;strong>When NOT&lt;/strong>: when your case is simple chat or RAG. It is overkill, expensive and complicated for problems that the previous modalities solve.&lt;/p>
&lt;h3 id="decision-matrix">Decision matrix&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Observed problem&lt;/th>
&lt;th>Modality&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Inconsistent answers in tone/structure&lt;/td>
&lt;td>Fine-tuning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model makes things up (hallucinates)&lt;/td>
&lt;td>RAG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Out-of-date knowledge (&amp;gt;1 year)&lt;/td>
&lt;td>RAG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model chooses tools badly&lt;/td>
&lt;td>Agent training (RLAIF/RFT)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mixed behaviour + knowledge&lt;/td>
&lt;td>Hybrid (fine-tune + RAG)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-step trajectory fails&lt;/td>
&lt;td>Agent training&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A specific language/regional style&lt;/td>
&lt;td>Fine-tuning&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="the-2026-verdict-hybrid-is-the-default">The 2026 verdict: hybrid is the default&lt;/h3>
&lt;p>&lt;a href="https://www.scalacode.com/blog/rag-vs-fine-tuning/">Multiple reports&lt;/a> agree that in 2025-2026, &lt;strong>around 60% of production projects use hybrid&lt;/strong>: fine-tuning for behaviour + RAG for knowledge. The key insight:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Fine-tune for behaviour (brand voice, decision protocol, output structure); use RAG for volatile knowledge you need the model to cite. Do not force a single tool to do both jobs.&lt;/strong>&lt;/p>
&lt;/blockquote>
&lt;p>A practical observation: the biggest quality improvements of 2025-2026 come from better reranking in RAG (cross-encoders), not from better embeddings. Rerankers add 15-35% of quality with little complexity.&lt;/p>
&lt;p>On cost: combined fine-tuning + RAG tends to be 30-50% cheaper than pure RAG with frontier models at high volume, because the fine-tuned model can be smaller and cheaper while keeping equivalent quality.&lt;/p>
&lt;h2 id="the-2026-tool-landscape">The 2026 tool landscape&lt;/h2>
&lt;p>On to the concrete pieces, grouped by function. The field has matured enough for each piece to have 2-3 reasonable options and a couple of leaders.&lt;/p>
&lt;h3 id="experiment-tracking-and-model-registry">Experiment tracking and model registry&lt;/h3>
&lt;p>&lt;a href="https://mlflow.org/">MLflow&lt;/a> remains the de facto standard, now with LLM-specific traction. MLflow 3 was published in June 2025; version 3.10.1 (March 2026) added:&lt;/p>
&lt;ul>
&lt;li>A GenAI Overview dashboard with ready-made metrics for LLM apps.&lt;/li>
&lt;li>Multi-workspace support for large teams.&lt;/li>
&lt;li>Cost tracking in traces (gen_ai.usage.* aggregated per experiment).&lt;/li>
&lt;li>MemAlign: a new specific eval algorithm.&lt;/li>
&lt;li>Integrated native OpenTelemetry tracing.&lt;/li>
&lt;li>First-class support for LangChain, LlamaIndex, AutoGen as frameworks.&lt;/li>
&lt;/ul>
&lt;p>MLflow treats prompts and agents as first-class citizens alongside classic models. It is the biggest change relative to MLflow 2.x.&lt;/p>
&lt;p>&lt;a href="https://wandb.ai/">Weights &amp;amp; Biases (W&amp;amp;B)&lt;/a> with its LLM-specific product Weave offers tracing + eval + debug with a very polished UI. More commercial, less self-host friendly, but excellent UX.&lt;/p>
&lt;p>&lt;a href="https://www.zenml.io/">ZenML&lt;/a> is the piece that most cleanly integrates &amp;ldquo;classic MLOps + emerging LLMOps&amp;rdquo; into a single framework. Its automatic artifact versioning captures prompt templates, retrieval chunks and agent conversation histories with no extra work. Open-source. The most complete unification option that exists.&lt;/p>
&lt;h3 id="dataset-versioning">Dataset versioning&lt;/h3>
&lt;p>&lt;a href="https://dvc.org/">DVC&lt;/a> remains the OSS standard. It extends Git to large files and pipelines. Important news from November 2025: &lt;a href="https://medium.com/the-modern-scientist/reproducible-ai-versioning-models-prompts-and-data-96dd0337af65">lakeFS acquired DVC&lt;/a>, consolidating the two OSS data versioning projects under one organisation. The combined roadmap is oriented towards LLM training and RAG datalakes specifically.&lt;/p>
&lt;p>&lt;strong>Typical pattern&lt;/strong>: Git for code + DVC for data/models + MLflow or W&amp;amp;B for experiment tracking + registry. Few teams use just one; the combination is what covers the cycle. Detailed in the &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">dedicated post on data versioning for LLMOps&lt;/a> — the four artifacts to version separately, schema contracts, end-to-end lineage, and a golden eval set with a strict holdout.&lt;/p>
&lt;h3 id="prompt-versioning-and-observability">Prompt versioning and observability&lt;/h3>
&lt;p>Covered in depth in the &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight post&lt;/a> where we go deeper into Langfuse as the OSS reference. Summary here:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://langfuse.com/">Langfuse&lt;/a>: MIT, self-host, prompt management with v1/v2/v3 versioning + labels + cache + linkage with traces.&lt;/li>
&lt;li>&lt;a href="https://www.langchain.com/langsmith">LangSmith&lt;/a>: if your stack is LangChain.&lt;/li>
&lt;li>&lt;a href="https://phoenix.arize.com/">Arize Phoenix&lt;/a>: ELv2, OTel-native.&lt;/li>
&lt;/ul>
&lt;h3 id="pipeline-orchestration">Pipeline orchestration&lt;/h3>
&lt;p>For the LLMOps pipeline steps, the dominant options:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.kubeflow.org/docs/components/pipelines/">Kubeflow Pipelines&lt;/a>: the K8s-native standard. KServe (the serving part of Kubeflow) has its vLLM runtime upgraded to v0.8.1+ with support for reasoning models, tool calling, embeddings, reranking, Llama 4 and Qwen 3.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/">ZenML&lt;/a>: already mentioned; also a pipeline orchestrator.&lt;/li>
&lt;li>&lt;a href="https://metaflow.org/">Metaflow&lt;/a> (Netflix-originated): Python-first pipelines, less LLM-specific but workable.&lt;/li>
&lt;li>&lt;a href="https://argoproj.github.io/workflows/">Argo Workflows&lt;/a>: a pure K8s OSS alternative.&lt;/li>
&lt;li>&lt;a href="https://flyte.org/">Flyte&lt;/a>: Kubernetes-native, OSS.&lt;/li>
&lt;/ul>
&lt;h3 id="serving">Serving&lt;/h3>
&lt;p>Covered in depth in &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a>. Summary:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/vllm-project/production-stack">vLLM Production Stack&lt;/a>: a curated Helm chart.&lt;/li>
&lt;li>&lt;a href="https://kserve.github.io/website/">KServe vLLM runtime&lt;/a>: K8s-native, vLLM 0.8.1+ with full agentic support.&lt;/li>
&lt;li>&lt;a href="https://www.bentoml.com/">BentoML&lt;/a>: flexible serving, popular in startups for its simplicity.&lt;/li>
&lt;li>&lt;a href="https://developer.nvidia.com/dynamo">NVIDIA Dynamo&lt;/a>: the successor to Triton.&lt;/li>
&lt;li>&lt;a href="https://github.com/llm-d/llm-d">llm-d&lt;/a>: CNCF Sandbox.&lt;/li>
&lt;li>&lt;a href="https://github.com/ome-projects/ome">OME&lt;/a>: an LMSYS operator with native SGLang.&lt;/li>
&lt;/ul>
&lt;h3 id="evals-and-guardrails">Evals and guardrails&lt;/h3>
&lt;p>Covered in &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a>. Ultra-short summary:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Evals CI&lt;/strong>: DeepEval, Promptfoo, Ragas.&lt;/li>
&lt;li>&lt;strong>Evals platform&lt;/strong>: Langfuse, LangSmith, Phoenix, Braintrust.&lt;/li>
&lt;li>&lt;strong>Guardrails&lt;/strong>: NeMo Guardrails, Llama Guard 4, Llama Prompt Guard 2, LLM Guard, Lakera.&lt;/li>
&lt;/ul>
&lt;h3 id="drift-detection-and-observability">Drift detection and observability&lt;/h3>
&lt;p>Covered in the &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">closing eBPF post&lt;/a>. Summary:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Drift&lt;/strong>: Evidently AI, NannyML, WhyLabs.&lt;/li>
&lt;li>&lt;strong>Tracing&lt;/strong>: Langfuse, OpenLLMetry, Phoenix.&lt;/li>
&lt;li>&lt;strong>eBPF&lt;/strong>: AgentSight, Hubble, Tetragon, ProfInfer.&lt;/li>
&lt;/ul>
&lt;h3 id="the-typical-2026-stack-table">The typical 2026 stack table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th>Dominant piece&lt;/th>
&lt;th>Alternatives&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Data ingestion + versioning&lt;/td>
&lt;td>DVC + lakeFS (unified Nov 2025)&lt;/td>
&lt;td>Pachyderm, Quilt&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Vector store / RAG index&lt;/td>
&lt;td>Milvus, Qdrant, pgvector, Weaviate&lt;/td>
&lt;td>LanceDB, Pinecone, Chroma&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Experiment tracking&lt;/td>
&lt;td>MLflow 3.10&lt;/td>
&lt;td>W&amp;amp;B Weave, Neptune&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pipeline orchestration&lt;/td>
&lt;td>Kubeflow + Argo&lt;/td>
&lt;td>ZenML, Metaflow, Flyte&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model registry&lt;/td>
&lt;td>MLflow registry&lt;/td>
&lt;td>W&amp;amp;B Models, KServe ModelMesh&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Prompt versioning&lt;/td>
&lt;td>Langfuse&lt;/td>
&lt;td>LangSmith, MLflow Prompts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Serving&lt;/td>
&lt;td>vLLM Production Stack&lt;/td>
&lt;td>KServe, BentoML, Dynamo, llm-d, OME&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Evals CI&lt;/td>
&lt;td>DeepEval, Ragas&lt;/td>
&lt;td>Promptfoo, OpenAI Evals&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Evals platform&lt;/td>
&lt;td>Langfuse, Phoenix&lt;/td>
&lt;td>LangSmith, Braintrust&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Guardrails&lt;/td>
&lt;td>NeMo + Llama Guard&lt;/td>
&lt;td>LLM Guard, Lakera&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tracing&lt;/td>
&lt;td>OpenLLMetry + Langfuse&lt;/td>
&lt;td>Phoenix, LangSmith&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Drift detection&lt;/td>
&lt;td>Evidently AI&lt;/td>
&lt;td>NannyML, WhyLabs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>eBPF observability&lt;/td>
&lt;td>AgentSight + Tetragon + Hubble&lt;/td>
&lt;td>(new territory, few alternatives)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>13 pieces. No org uses them all; any serious org uses at least six. &lt;strong>This is the current LLMOps stack&lt;/strong>.&lt;/p>
&lt;h2 id="the-operational-reality-nobody-uses-a-single-tool">The operational reality: nobody uses a single tool&lt;/h2>
&lt;p>&lt;a href="https://medium.com/@kanerika/mlflow-vs-kubeflow-vs-w-b-which-mlops-tool-fits-your-stack-b59007460b25">Multiple comparisons&lt;/a> agree on one thing: &lt;strong>the teams that win combine&lt;/strong>. Recurring patterns:&lt;/p>
&lt;ul>
&lt;li>ZenML to orchestrate + MLflow for tracking + KServe for serving: the most popular OSS stack in companies coming from classic MLOps.&lt;/li>
&lt;li>Kubeflow + W&amp;amp;B + BentoML: for teams with a research focus.&lt;/li>
&lt;li>Langfuse + DeepEval + Phoenix + LiteLLM: for pure-LLM teams with no classic MLOps background.&lt;/li>
&lt;li>MLflow + DVC + Argo + KServe: an idiomatic cloud-native stack without additional LLM-specifics (with its limitations).&lt;/li>
&lt;/ul>
&lt;p>The choice depends on the team&amp;rsquo;s background, on the licensing model they can afford, on the level of self-hosting they need, and on which frictions blocked them most in previous projects. &lt;strong>There is no &amp;ldquo;one right answer&amp;rdquo;&lt;/strong>; there is a stable meta-pattern of layers worth covering.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;h3 id="treating-the-prompt-as-text-in-code">Treating the prompt as text in code&lt;/h3>
&lt;p>Hardcoding prompts in strings in the repo. Changing them requires a PR + redeploy. Result: teams that do not iterate on prompts because every change costs hours of pipeline. &lt;strong>Solution&lt;/strong>: externalised prompt management (Langfuse, MLflow Prompts) with versioning, labels, hot-reload.&lt;/p>
&lt;h3 id="skipping-dataset-versioning">Skipping dataset versioning&lt;/h3>
&lt;p>&amp;ldquo;DVC is complicated, we will add it later&amp;rdquo;. Result: two months later, nobody knows which dataset trained which model. Impossible to reproduce incidents. &lt;strong>Solution&lt;/strong>: DVC + lakeFS from day 1, even if only with a small subset.&lt;/p>
&lt;h3 id="mixing-layers-in-the-same-pipeline">Mixing layers in the same pipeline&lt;/h3>
&lt;p>Teams that put ingestion, fine-tuning, eval and deploy into a single giant pipeline. When something fails, the whole pipeline fails. &lt;strong>Solution&lt;/strong>: independent pipelines per stage, with versioned artifacts as the interfaces between them.&lt;/p>
&lt;h3 id="tracking-with-no-structure">Tracking with no structure&lt;/h3>
&lt;p>Logging everything to stdout and &amp;ldquo;we will look at it in CloudWatch later&amp;rdquo;. Result: impossible to correlate, compare, debug. &lt;strong>Solution&lt;/strong>: OTel from day 1 with &lt;code>gen_ai.*&lt;/code> semantic conventions.&lt;/p>
&lt;h3 id="evals-that-block-nothing">Evals that block nothing&lt;/h3>
&lt;p>You have evals, you run them, you look at them, but they do not prevent the deploy if they drop. Eventually it drops gradually and nobody notices. &lt;strong>Solution&lt;/strong>: eval gates in CI/CD that block the merge if critical metrics regress by more than X%.&lt;/p>
&lt;h3 id="no-retrain-cadence">No retrain cadence&lt;/h3>
&lt;p>You launch v1 and never go back to the model. Six months later, drift has degraded it but the team is on other projects. &lt;strong>Solution&lt;/strong>: a formal retrain cadence (monthly, quarterly) tied to the production incident queue.&lt;/p>
&lt;h3 id="invisible-vendor-lock-in">Invisible vendor lock-in&lt;/h3>
&lt;p>You start with the OpenAI API + LangSmith + Pinecone. When you want to self-host, you discover that migrating is a 3-month project. &lt;strong>Solution&lt;/strong>: abstraction layers (LiteLLM, OpenLLMetry) and vendor-neutrality from the start.&lt;/p>
&lt;h2 id="what-is-coming-in-the-following-posts-of-the-series">What is coming in the following posts of the series&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Post 2 — &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over datalakes with Kafka: end-to-end technical architecture&lt;/a>&lt;/strong> — the most hands-on one. Kafka as source-of-truth, Flink CDC, embedding pipelines, continuous indexing in Milvus/Qdrant, a complete example with real numbers and manifests.&lt;/li>
&lt;li>&lt;strong>Post 3 — &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline: global architecture&lt;/a>&lt;/strong> — the master map of the complete system with a reusable &amp;ldquo;you are here&amp;rdquo; SVG for the following posts. A deep dive into each of the six stages (Data, Tune, Eval, Deploy, Observe, Retrain).&lt;/li>
&lt;li>&lt;strong>Post 4 — &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in the ingestion stage&lt;/a>&lt;/strong> — synchronisation patterns (dual-write, outbox + CDC, event-driven), a complete microservices architecture, a Qdrant cluster manifest.&lt;/li>
&lt;li>&lt;strong>Upcoming posts&lt;/strong> — still to be decided: the cluster as a multi-tenant platform, Constitutional AI / alignment runtime, continuous fine-tuning in depth, edge LLMs.&lt;/li>
&lt;li>&lt;strong>Synthesis post&lt;/strong> — &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production, May 2026&lt;/a> — the complete journey of a real request through the six stages and the two cross-cutting components, with cross-links to each dedicated post. It works as a mental map of the blog and as an integrator&amp;rsquo;s guide.&lt;/li>
&lt;li>&lt;strong>Parallel OSS vs hyperscalers catalogue&lt;/strong> — &lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">The parallel catalogue: the six LLMOps stages in OSS and in AWS / GCP / Azure&lt;/a> — for each stage of the pipeline, which tools the blog&amp;rsquo;s reference OSS stack uses and what the cloud equivalents are, with summary tables, identification of real gaps and the multi-tenant chatbot ported to an AWS stack component by component. A neutral editorial stance.&lt;/li>
&lt;li>&lt;strong>OSS catalogue entry by entry&lt;/strong> — &lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages: entry by entry&lt;/a> — the zoom in on the open source side: ~150 words of description per core tool (vLLM, Langfuse, DVC, Qdrant, Airflow, NeMo Guardrails, Presidio…), licence and governance, a decision matrix per stage, a diagram of the connected OSS stack and a master table of licences and EE / SaaS offerings.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>LLMOps vs MLOps:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/@sanjeebmeister/the-complete-mlops-llmops-roadmap-for-2026-building-production-grade-ai-systems-bdcca5ed2771">The Complete MLOps/LLMOps Roadmap for 2026 (Sanjeeb Panda)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.iamraghuveer.com/posts/mlops-vs-llmops-what-changes/">MLOps vs LLMOps: What Changes (Raghuveer)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://hyscaler.com/insights/mlops-in-2026-guide/">MLOps in 2026: Architecture, Trends &amp;amp; Strategy (Hyscaler)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.ideas2it.com/blogs/llmops-vs-mlops-key-differences-and-evolution">LLMOps vs MLOps: Differences and Evolution (Ideas2IT)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://dev.to/apprecode/llmops-vs-mlops-whats-different-whats-the-same-and-how-to-run-both-in-production-2o52">LLMOps vs MLOps in production (DEV/Apprecode)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Tools:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mlflow.org/">MLflow&lt;/a> — registry + tracking + serving.&lt;/li>
&lt;li>&lt;a href="https://wandb.ai/site/weave">Weights &amp;amp; Biases Weave&lt;/a> — LLM tracing.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/">ZenML&lt;/a> — pipeline orchestration MLOps + LLMOps.&lt;/li>
&lt;li>&lt;a href="https://www.kubeflow.org/">Kubeflow&lt;/a> — K8s-native MLOps.&lt;/li>
&lt;li>&lt;a href="https://kserve.github.io/website/">KServe&lt;/a> — model serving K8s.&lt;/li>
&lt;li>&lt;a href="https://www.bentoml.com/">BentoML&lt;/a> — flexible serving.&lt;/li>
&lt;li>&lt;a href="https://metaflow.org/">Metaflow&lt;/a> — Netflix&amp;rsquo;s pipelines.&lt;/li>
&lt;li>&lt;a href="https://dvc.org/">DVC&lt;/a> — dataset versioning.&lt;/li>
&lt;li>&lt;a href="https://lakefs.io/">lakeFS&lt;/a> — enterprise data versioning, acquired DVC in Nov 2025.&lt;/li>
&lt;/ul>
&lt;p>2026 comparisons:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/@kanerika/mlflow-vs-kubeflow-vs-w-b-which-mlops-tool-fits-your-stack-b59007460b25">MLflow vs Kubeflow vs W&amp;amp;B (Kanerika)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/blog/mlflow-alternatives">9 MLflow alternatives tested (ZenML)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/blog/metaflow-vs-kubeflow">Metaflow vs Kubeflow vs ZenML (ZenML)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/blog/mlops-tools">12 Best MLOps Tools for Agentic AI (ZenML)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.spheron.network/blog/mlops-pipeline-gpu-cloud-kubeflow-zenml-metaflow-2026/">MLOps Pipeline on GPU Cloud 2026 (Spheron)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://northflank.com/blog/top-7-kubeflow-alternatives">Top 7 Kubeflow alternatives 2026 (Northflank)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.sganalytics.com/blog/mlops-tools/">Top 20 MLOps Tools 2026 (SG Analytics)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>RAG vs Fine-Tuning:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.scalacode.com/blog/rag-vs-fine-tuning/">RAG Vs Fine-Tuning In 2026 (ScalaCode)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.arxiv.org/pdf/2510.01375">Fine-Tuning with RAG (ICLR 2026, arxiv)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://dev.to/tyson_cung/rag-vs-fine-tuning-what-actually-works-in-production-2026-20jg">RAG vs Fine-Tuning — What Actually Works in Production 2026 (DEV)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.kapa.ai/blog/how-to-build-a-rag-pipeline-from-scratch-in-2026">How to Build a RAG Pipeline from Scratch in 2026 (kapa.ai)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Cross-references (the three previous series):&lt;/p>
&lt;ul>
&lt;li>LLM inference series: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on K8s&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a>.&lt;/li>
&lt;li>eBPF series: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a>.&lt;/li>
&lt;li>Post-tracing series: &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>