CourseLarge Language Models · Module 9: Adaptation: Fine-Tuning and Customization · part 49 of 80
Part 49 · Module 9: Adaptation: Fine-Tuning and Customization

Part 6: Serving many adapters over one base model

5 min read·22 Sept 2026

Part 6: Serving many adapters over one base model

Brightlane now has three adapters over the same TinyLM base: triage, reply SFT, and reply DPO. Deploying three full fine-tuned copies would triple the memory. With LoRA, you load the base once and keep each adapter's A and B matrices, a few hundred KB each, ready to copy into place. Production servers do the same thing at scale and can even mix requests for different adapters in one batch (S-LoRA, Sheng et al., 2023, and vLLM's multi-LoRA support). Here is the single-request version:

.

examples/m09_serve.py

python
"""Serve several LoRA adapters over ONE base model: load the base once, swap adapter weights per request."""
import time

import torch

from examples.m09_dpo import fixed_prompt
from examples.m09_lora import add_lora, with_adapter
from examples.m09_setup import MODELS, classify, prompt_for
from supportdesk.data import load_tickets
from supportdesk.tinylm import SamplingParams, generate, load


class AdapterServer:
    """One base model with LoRA slots of rank `max_rank`; each adapter is zero-padded to fit.

    Padding is exact: extra rows of A and columns of B are zero, and each adapter's alpha/r
    scaling is folded into its B, so every adapter computes exactly what it computed alone.
    """

    def __init__(self, max_rank: int = 8) -> None:
        self.model, self.tok = load()
        add_lora(self.model, r=max_rank, alpha=max_rank)  # slot scaling = 1
        self.max_rank = max_rank
        self.adapters: dict[str, dict[str, torch.Tensor]] = {}
        self.active = None

    def register(self, name: str, path) -> None:
        state = torch.load(path, weights_only=True)
        r, alpha = state["meta"]["r"], state["meta"]["alpha"]
        padded = {}
        for key, value in state["lora"].items():
            if key.endswith("lora_A"):
                padded[key] = torch.cat([value, torch.zeros(self.max_rank - r, value.shape[1])])
            else:
                padded[key] = torch.cat([value * (alpha / r), torch.zeros(value.shape[0], self.max_rank - r)], dim=1)
        self.adapters[name] = padded

    @torch.no_grad()
    def use(self, name: str) -> float:
        """Copy one adapter into the LoRA slots; returns milliseconds spent (0 if already active)."""
        if name == self.active:
            return 0.0
        started = time.perf_counter()
        params = dict(self.model.named_parameters())
        for key, value in self.adapters[name].items():
            params[key].copy_(value)
        self.active = name
        return (time.perf_counter() - started) * 1000


if __name__ == "__main__":
    started = time.perf_counter()
    server = AdapterServer(max_rank=8)
    base_ms = (time.perf_counter() - started) * 1000
    for name in ("m09-lora-triage", "m09-reply-sft", "m09-reply-dpo"):
        server.register(name, MODELS / name / "adapter.pt")
    print(f"base loaded once in {base_ms:.0f} ms; adapters registered: {list(server.adapters)}")

    # Exactness check: padded adapter in the server == the adapter loaded on its own.
    x = torch.tensor([server.tok.encode(fixed_prompt(0)).ids])
    server.use("m09-lora-triage")
    alone = with_adapter("m09-lora-triage")
    with torch.no_grad():
        print("max logit difference vs standalone adapter:", float((server.model(x) - alone(x)).abs().max()))

    tickets = load_tickets("test")[:3]
    requests = [("triage", t) for t in tickets] + [("reply", 10), ("triage", load_tickets("test")[3]), ("reply", 11)]
    swaps = []
    for kind, item in requests:
        if kind == "triage":
            ms = server.use("m09-lora-triage")
            out = classify(server.model, server.tok, prompt_for(server.tok, item))
            label = item.id
        else:
            ms = server.use("m09-reply-dpo")
            out = generate(server.model, server.tok, fixed_prompt(item),
                           SamplingParams(max_new_tokens=30, temperature=0, stop=["\n"])).text.strip()
            label = f"question {item + 1}"
        if ms:
            swaps.append(ms)
        print(f"  {kind:<6} {label:<11} swap {ms:5.2f} ms  -> {out[:70]!r}")
    timings = []
    for _ in range(50):
        server.active = None
        timings.append(server.use("m09-reply-dpo"))
    timings.sort()
    started = time.perf_counter()
    load(MODELS / "m09-full")
    full_ms = (time.perf_counter() - started) * 1000
    print(f"adapter swap: median {timings[25]:.2f} ms over 50 swaps; loading a full fine-tuned model from disk: {full_ms:.0f} ms")

Code explained

  • In simple words: one base model in memory with empty LoRA slots; each request copies its task's adapter into the slots, which takes a fraction of a millisecond.
  • What happens:
    • AdapterServer.__init__ loads the base once and adds LoRA slots of rank max_rank (8) with alpha equal to the rank, so the slot's scaling is 1.
    • register reads an adapter file and pads it to the slot size: extra rows of A and columns of B are zero, so they add nothing. Each adapter's own alpha / r is folded into its B, so a rank-4 adapter with scaling 2 computes exactly what it computed alone.
    • use copies the adapter's tensors into the slots in place (no new model, no reallocation) and times it. It does nothing if that adapter is already active.
    • Main: register three adapters, prove exactness (the maximum logit difference between the server and the standalone triage adapter), serve a mixed stream of triage and reply requests, time 50 swaps, and time loading a full fine-tuned model from disk for comparison. Run it with python -m examples.m09_serve.
  • Comes out:
text
base loaded once in 32 ms; adapters registered: ['m09-lora-triage', 'm09-reply-sft', 'm09-reply-dpo']
max logit difference vs standalone adapter: 0.0
  triage T-1003      swap  0.00 ms  -> 'cancellation'
  triage T-1006      swap  0.00 ms  -> 'how_to'
  triage T-1009      swap  0.00 ms  -> 'how_to'
  reply  question 11 swap  0.23 ms  -> 'Thank you. Team costs 12 USD per user per month. Business costs 24 USD'
  triage T-1012      swap  0.19 ms  -> 'billing'
  reply  question 12 swap  0.19 ms  -> 'Thank you. Please check status.brightlane.example for live updates.'
adapter swap: median 0.11 ms over 50 swaps; loading a full fine-tuned model from disk: 23 ms

The padded adapter reproduces the standalone model exactly (difference 0.0). A swap takes about 0.1 to 0.2 ms, against about 23 ms to load a full fine-tuned TinyLM from disk and about 30 ms for the base. For TinyLM both are fast. For a 7B model, loading a full copy means gigabytes and seconds, while an adapter swap stays in the milliseconds, which is why "one base, many adapters" is the standard way to serve per-task or per-customer fine-tunes. The ratio, not the absolute number, is what transfers. (The triage outputs here are the adapter's usual mistakes: T-1006 and T-1009 are wrong, as Part 4 predicted.)

SituationUse thisWhy
One fine-tuned task, latency-criticalMerge the adapter into the base (merge_lora)Zero extra compute per token
A few tasks on one deploymentOne base with swappable adaptersOne copy of the big weights; swap cost is negligible
Many tenants or tasks with concurrent trafficA server with batched multi-LoRA (vLLM, S-LoRA-style)Different adapters in one batch without swapping
Tasks that need different base modelsSeparate deploymentsAdapters only fit the base they were trained on