Prompt cache and session id

Model servers keep the prompt they have just read (the prompt cache), so that the next request that starts with the same text doesn't have to read it again. For a long story that is the difference between waiting a few seconds and waiting minutes. A cache only helps as far as the new prompt starts like the old one (see Why a cache may not help). Marginalia can also tell the server what kind of work a request is, so that a server, or a proxy in front of it, can keep a cache for each kind.

What is sent

Every request Marginalia makes for a kind of work carries the HTTP header

X-Session-ID: 3f9a0c1d5b7e4a2c8d6f1e0b9a7c5d3e

The value is the same for the same user doing the same kind of work, whatever book it is about, and it differs between users and between kinds:

Kind Requests
generation A new part, Regenerate and Swipe: their prompts share the story, so they share the value.
impersonate Impersonate.
annotate Annotate with AI and automatic annotation; the second model of the two stage mode has a value of its own.
redraft Redraft and the redraft of automatic annotation.
summary Summaries.
agent The requests of an agent; every run has a value of its own, as it grows its own conversation.
help The help assistant.
plugins Each plugin has its own: Reviewer, Side Query (its tab titles have another), Lore Generator, Arc Outline, Converter and Manga Generator.

Requests that belong to no kind (listing the models, testing the connection, counting tokens) send no header.

The value is a keyed hash of the user and the kind (the key comes from secret.key in the data folder). It reveals neither the user nor the book, nobody can work out who it belongs to, and another installation gives another value for the same user. There is one value per user and kind, not one per book, so a server never has to keep more caches than that.

All providers send it: OpenAI-compatible ones, and the Anthropic Claude and Google Gemini providers. A server that doesn't use the header ignores it. There is no setting to switch it off.

Why a cache may not help

A server reuses the part of the cache where the new prompt is the same as the old one from the very first token; the first difference ends it, and everything after it is read again, however much of it is unchanged. Look at the llama-server line printed when a request starts:

selected slot by LCP similarity, sim_best = 0.163, f_keep = 0.151

f_keep is the share of the cached prompt the new request keeps. Near 1 the cache is used; here only 15 % is, so the prompt differs from the cached one early on, whether the cache was restored from disk or never left the slot.

Models with a sliding window (Gemma, for one) and hybrid models can not drop the end of their cache and go on from the middle. As far as the llama.cpp source says, llama-server can only continue from a cached prompt when the new prompt extends it exactly, or when a context checkpoint exists at or before the place where the two differ. It makes checkpoints only at the start of user messages (at least 8192 tokens apart) and shortly before the end of the prompt, and keeps them in the slot file (INFO lines appended N context checkpoint(s) and restored N context checkpoint(s) tell when it does). Without a usable checkpoint it reads the whole prompt again, whatever the f_keep says, and it reports that only at trace level (-lv 4: forcing full prompt re-processing due to lack of cache data). That is why the story of a generation, of an annotation and of a redraft is sent as a chain of messages, each part as an answer of the model after a message of the user: the last message of the user is checkpointed, which is where the next request starts to differ, and a new part only adds messages at the end. Reading a cold prefill off the log: the first prompt processing, n_tokens = 4096 line comes after about 7 s here; if a prefix had been reused it would come after a second or two.

The prompt starts with the system message, and the story comes after it, so what is in the system message must stay the same from one request to the next. In the default master template the system message holds, in this order, the lore block, the summaries and the author's notes (annotations). When any of them changes between two requests, which is usual (the new text activates other lore entries, a summary is added, a note is added or resolved), the cache is valid only up to that point and the whole story behind it is read again. The oldest parts leave the prompt first when the story no longer fits, which moves the start of the story by one part on every turn. What helps:

  • Give the entries that change from part to part the insertion mode Before User Prompt (Order and insertion): they go to the end of the prompt, behind the story, and do not disturb the cache. Keep what is always true in the lore block.

  • Leave the blocks you do not use out of the master template (the author's notes, for example, if you do not give notes to the model), so that the system message stays the same.

  • Open Compare Prompt in the menu of a part to see where the cache ends: it compares the prompt of the part with the one of an earlier part (the nearest one by default; the box lists the parts before it and its swipes) and marks the first message that differs. See Compare Prompt.

Compare Prompt

Compare Prompt in the menu of a part (desktop and phone) answers why was the cache not used for this part? It lists the messages of the two prompts (the system message, then a user message and a model answer per part, then the user message of the turn) with a status for each: the same, changed, added or deleted. The first message that differs is highlighted and the ones after it are marked Read again, because a server reads everything after the first difference again. Above the table:

  • where the first difference is (message and character) and how much of the prompt, in characters and about in tokens, is the same from the start;

  • a reading of the difference: the system message differs (the jailbreak, the template, the lore, the summaries or the notes changed between the two requests: the usual cause), a part in the middle of the story differs (it was edited, regenerated or replaced by a summary), the start of the story moved (the oldest parts were left out or summarised), or the difference is only the last message of the earlier prompt, the user message of that turn, which the story does not repeat. That last case is the most a next part or a swipe can reuse; if the server still did not use its cache, the prompt is not the cause (check the slot, the model and the session id).

Selecting a message shows the two texts side by side, from just before the first difference. Only the messages are compared: the model, the tools of the request and the server slot are not part of the stored prompt. The part needs a stored prompt (parts typed by the author and parts generated before the prompts were kept have none).

The request kinds below matter for what is kept when you switch between them; the prompt decides how much of it is used.

Using it: one slot on disk with llama.cpp

The value is for you if the server runs out of memory for caches. A local llama-server with enough memory for several slots can keep a cache for each kind of work and needs nothing more. With memory for one slot, every other kind of work throws the cache of the story away: the annotation of a part, the redraft after it and the next generation read their prompts again each time.

The way out is to keep the cache of every kind on disk and swap it into the one slot when a request of that kind arrives. llama-server can save a slot to a file and restore it; a proxy in front of it knows which kind a request is from the header. The example below is a hook for LiteLLM.

Requirement: a llama-server from 2026-10-08 or later (llama.cpp PR #26004, preserve context checkpoints across slot save/restore). An older one writes and restores the memory of the slot but not its context checkpoints, so a model with a sliding window (Gemma, for one) or a hybrid model reads the whole prompt again after every restore, however much of it is unchanged. Check the build with llama-server --version; a container image with a moving tag (full, server...) is not updated until you pull it.

Start llama-server with one slot and a folder for the files:

llama-server -m model.gguf -c 65536 -np 1 --slot-save-path /var/cache/llama-slots

Save this as slot_cache.py next to the LiteLLM configuration:

"""LiteLLM hook: keeps the prompt cache of llama-server on disk, one file per X-Session-ID.

llama-server runs with ONE slot (-np 1) and --slot-save-path. Marginalia sends the header X-Session-ID, a hash of the
user and the kind of work (generation, annotate, redraft...). When a request for another session arrives, the hook saves
the slot under the name of the session that was in it and restores the file of the new one (or empties the slot when
there is none yet), so every kind of work finds its own cache and only one is in memory.

A saved state fits only the model that wrote it, so the files are kept per route: the name is the route (the model_name
of the LiteLLM config) and the session, and the limits below count the files of one route. Routes never share a file,
also when they point at the same server or share a folder. After you change the model behind a route, delete its files.

The server is the one the request is going to (its api_base and api_key in the LiteLLM config); only deployments that
ask for it are touched, with `model_info` in config.yaml. One wildcard entry covers a llama-server in router mode, no
model has to be listed (the route is then the model the client asked for):

    model_list:
      - model_name: "*"
        litellm_params: {model: "openai/*", api_base: "http://llama-server:8080/v1", api_key: none}
        model_info:
          slot_cache: true              # or the settings below, each one optional:
          # slot_cache: {slot: 0, save_path: /var/cache/llama-slots, max_files: 8, max_gib: 20, model: qwen}

In router mode every model is an instance of its own with its own slots: the slot calls name the model (the model of the
request without the `openai/` of LiteLLM, or `model` of the settings), so the state is kept per server, model and slot.
This needs a llama.cpp that routes /slots (PR 19854, 2026-02).

  slot        the slot to use, default 0 (environment LLAMA_SLOT)
  model       the model the router knows, when it is not the one in the request
  save_idle_seconds
              also save the cache of the session in the slot when no request came for this long (default 0: only
              when the slot is needed for another session or model; environment LLAMA_SAVE_IDLE_SECONDS). A save
              is sent when the slot is idle, and the server does nothing else while it writes.
  models_max  the --models-max of the router, default 4 (environment LLAMA_MODELS_MAX), 0 for no limit. The router
              unloads the model used longest ago when a request needs more than that; its slot is gone with it. The hook
              follows the same order and saves that slot BEFORE the request that makes the router unload it. Requests
              that reach the router without this proxy are not seen, so it must be the only client of the router.
  save_path   the folder of --slot-save-path as THIS process sees it; without it the files are never deleted
              (environment LLAMA_SLOT_SAVE_PATH)
  max_files   keep at most this many saved caches of the route, default no limit (environment LLAMA_CACHE_MAX_FILES)
  max_gib     keep at most this many GiB of saved caches of the route, default no limit (environment LLAMA_CACHE_MAX_GIB)
When a limit is passed, the caches that were used the longest ago are deleted first.

A save, restore or erase is only sent when the slot is idle (GET /slots, is_processing): llama-server parks such a call
while a request runs, and only one parked call is released each time a request ends, so a request of another client that
arrived behind it could wait there indefinitely. Without /slots the calls are sent at once.
"""
import asyncio
import collections
import hashlib
import logging
import os
import re

import httpx
from litellm.integrations.custom_logger import CustomLogger

try:
    # the logger of the proxy: its INFO lines show with LITELLM_LOG=INFO
    from litellm._logging import verbose_proxy_logger as log
except ImportError:
    log = logging.getLogger("slot_cache")

SLOT = int(os.environ.get("LLAMA_SLOT", "0"))
SAVE_PATH = os.environ.get("LLAMA_SLOT_SAVE_PATH")
MAX_FILES = int(os.environ.get("LLAMA_CACHE_MAX_FILES", "0"))
MAX_GIB = float(os.environ.get("LLAMA_CACHE_MAX_GIB", "0"))
# --models-max of llama-server in router mode (its default is 4, 0 is no limit)
MODELS_MAX = int(os.environ.get("LLAMA_MODELS_MAX", "4"))
SAVE_IDLE_SECONDS = float(os.environ.get("LLAMA_SAVE_IDLE_SECONDS", "0"))
IDLE_POLL_SECONDS = 1.0         # how often a busy slot is looked at ...
IDLE_WAIT_MAX_SECONDS = 3600    # ... and for how long at most
# the header is client input and becomes a file name: only what Marginalia sends is accepted
SESSION_ID = re.compile(r"^[0-9a-f]{32}$")


def route_tag(route):
    """The part of a file name that is the route: readable, safe as a file name, and told apart by a hash of the name."""
    return re.sub(r"[^A-Za-z0-9_-]", "_", route)[:40] + "-" + hashlib.sha256(route.encode()).hexdigest()[:8]


class CacheFolder:
    """The saved caches of one route in the folder of llama-server; deletes the least recently used ones beyond the
    limits (0 is no limit). The files of other routes are not touched and not counted."""

    def __init__(self, path, max_files=0, max_bytes=0, tag=""):
        self.path = path
        self.max_files = max_files
        self.max_bytes = max_bytes
        self.files = re.compile("^" + re.escape(tag) + r"_[0-9a-f]{32}\.bin$")

    def has(self, session):
        return bool(self.path) and os.path.exists(os.path.join(self.path, session + ".bin"))

    def touch(self, session):
        """A cache that was restored was just used: it is the last one to go."""
        if self.path:
            try:
                os.utime(os.path.join(self.path, session + ".bin"))
            except OSError:
                pass

    def evict(self, keep=()):
        """Deletes the oldest caches until the limits hold, never those of the sessions in `keep`."""
        if not self.path or not (self.max_files or self.max_bytes):
            return []
        try:
            entries = []
            for name in os.listdir(self.path):
                if self.files.match(name):
                    stat = os.stat(os.path.join(self.path, name))
                    entries.append((stat.st_mtime, name, stat.st_size))
        except OSError as e:
            log.warning("Cannot list %s: %s", self.path, e)
            return []
        entries.sort()
        count = len(entries)
        size = sum(entry[2] for entry in entries)
        removed = []
        for _, name, length in entries:
            if (not self.max_files or count <= self.max_files) and (not self.max_bytes or size <= self.max_bytes):
                break
            if name[:-len(".bin")] in keep:
                continue
            try:
                os.remove(os.path.join(self.path, name))
            except OSError as e:
                log.warning("Cannot delete %s: %s", name, e)
                continue
            count -= 1
            size -= length
            removed.append(name)
        if self.max_bytes and size > self.max_bytes:
            log.warning("The cache of one session alone is over the limit of %d bytes", self.max_bytes)
        return removed


class SlotSwitcher:
    """One slot of one llama-server and the cache (route and session) that is in it."""

    def __init__(self, base_url, slot=SLOT, client=None, model=None):
        self.base_url = base_url.rstrip("/")
        self.slot = slot
        # a router sends the call to the instance of this model (the body is read by the router)
        self.model = model
        # a swap waits for the slot to be idle, which can be as long as a generation takes
        self.client = client or httpx.AsyncClient(timeout=None)
        self.headers = {}
        self.current = None
        self.folder = None
        self.version = 0            # counts the requests: what the slot holds changed since the last save
        self.saved_version = 0
        self._idle = None
        self._lock = asyncio.Lock()

    async def _busy(self):
        """Whether the slot is processing a request. False when the server does not tell."""
        try:
            response = await self.client.get(f"{self.base_url}/slots", headers=self.headers, timeout=15,
                                             params={"model": self.model} if self.model else None)
            slots = response.json() if response.is_success else None
            return isinstance(slots, list) and any(
                slot.get("is_processing") for slot in slots
                if isinstance(slot, dict) and slot.get("id", self.slot) == self.slot)
        except (httpx.HTTPError, ValueError):
            return False

    async def _wait_idle(self):
        deadline = asyncio.get_running_loop().time() + IDLE_WAIT_MAX_SECONDS
        while await self._busy() and asyncio.get_running_loop().time() < deadline:
            await asyncio.sleep(IDLE_POLL_SECONDS)

    async def _action(self, action, key=None):
        body = {}
        if self.model:
            body["model"] = self.model
        if key:
            body["filename"] = key + ".bin"
        # nothing to save in a model the router has unloaded: it must not be loaded for that
        query = f"action={action}" + ("&autoload=false" if action == "save" else "")
        try:
            response = await self.client.post(f"{self.base_url}/slots/{self.slot}?{query}", json=body or None,
                                              headers=self.headers)
            if response.status_code in (401, 403):
                log.warning("slot_cache: %s %s: the server refused the api key (%d)", self.base_url, action,
                            response.status_code)
            detail = ""
            if response.is_success:
                try:
                    detail = describe(response.json())
                except ValueError:
                    pass
            log.info("slot_cache: %s %s %s (%s%s): %s", self.base_url, action, key or "", self.model or "-",
                     "" if self.slot == 0 else f", slot {self.slot}",
                     "ok" + detail if response.is_success else
                     (f"miss ({response.status_code})" if action == "restore" else f"failed ({response.status_code})"))
            return response.is_success
        except httpx.HTTPError as e:
            # a cache that is not there is only slower, never an error of the request
            log.warning("slot_cache: %s %s: %s", self.base_url, action, e)
            return False

    async def switch(self, key, api_key=None, folder=None, idle=SAVE_IDLE_SECONDS):
        """Puts the cache `key` (the file name without .bin) in the slot; `folder` has the limits of its route.
        With `idle` the cache is saved when no request came for that many seconds."""
        folder = folder or CacheFolder(SAVE_PATH, MAX_FILES, int(MAX_GIB * 1024 ** 3))
        await self._switch(key, api_key, folder)
        self.version += 1
        if idle:
            if self._idle and not self._idle.done():
                self._idle.cancel()
            self._idle = asyncio.create_task(self._save_when_idle(idle))

    async def _save_when_idle(self, seconds):
        await asyncio.sleep(seconds)
        version = self.version
        await self._wait_idle()         # a new request cancels this task, also while it waits
        async with self._lock:
            if self.current is None or version == self.saved_version:
                return
            if await self._action("save", self.current):
                self.saved_version = version
                if self.folder:
                    await asyncio.to_thread(self.folder.evict, {self.current})

    async def _switch(self, key, api_key, folder):
        async with self._lock:
            if key == self.current:
                return
            # the key of the deployment that asks now (a placeholder is not a key)
            self.headers = {"Authorization": "Bearer " + api_key} if api_key and api_key != "none" else {}
            self.folder = folder
            await self._wait_idle()
            # a cache that was saved after its last request (the idle save) is on disk already
            unchanged = self.saved_version == self.version and self.current is not None and folder.has(self.current)
            if self.current is not None and (unchanged or await self._action("save", self.current)):
                # not the cache that was just saved, and not the one that is about to be restored
                await asyncio.to_thread(folder.evict, {self.current, key})
                self.saved_version = self.version
            if await self._action("restore", key):
                await asyncio.to_thread(folder.touch, key)
            else:
                await self._action("erase")
            self.current = key

    async def release(self, api_key=None):
        """The model is about to be unloaded by the router: its slot goes, so the cache in it is saved first."""
        async with self._lock:
            if self.current is None:
                return
            self.headers = {"Authorization": "Bearer " + api_key} if api_key and api_key != "none" else {}
            await self._wait_idle()
            if await self._action("save", self.current) and self.folder:
                await asyncio.to_thread(self.folder.evict, {self.current})
            if self._idle and not self._idle.done():
                self._idle.cancel()
            self.current = None


def describe(body):
    """What llama-server says about a slot call that worked: it logs nothing itself, the numbers are in the answer."""
    if not isinstance(body, dict):
        return ""
    timings = body.get("timings") if isinstance(body.get("timings"), dict) else {}
    try:
        if "n_restored" in body:
            return f" ({body['n_restored']} tokens, {body.get('n_read', 0) / 1e6:.0f} MB, {timings.get('restore_ms', 0) / 1000:.1f} s)"
        if "n_saved" in body:
            return f" ({body['n_saved']} tokens, {body.get('n_written', 0) / 1e6:.0f} MB, {timings.get('save_ms', 0) / 1000:.1f} s)"
        if "n_erased" in body:
            return f" ({body['n_erased']} tokens dropped)"
    except (TypeError, ValueError):
        pass
    return ""


def text_of(message):
    """The text of a chat message: a string, or the text parts of a list."""
    content = message.get("content") if isinstance(message, dict) else None
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        return "".join(part.get("text", "") for part in content if isinstance(part, dict))
    return ""


def compare(previous, messages):
    """Says where a prompt first differs from the last one of its session: a server can reuse its cache up to there, and not
    after it, however much of the rest is the same. `previous` and `messages` are lists of (role, text). The last message is
    compared with the last one of the other prompt as well: it holds what changes, and when a story grew by a message it is not
    at the same place."""
    shared = 0
    while shared < min(len(previous), len(messages)) and previous[shared] == messages[shared]:
        shared += 1
    if shared == len(previous) == len(messages):
        return f"the same as the last one ({len(messages)} messages)"
    if shared == len(previous) or shared == len(messages):
        said = (f"{len(messages)} messages, the last one had {len(previous)}: the first {shared} are the same, "
                f"{'new messages follow' if shared == len(previous) else 'the end is cut'}")
    else:
        role, text = messages[shared]
        same = len(os.path.commonprefix([previous[shared][1], text]))
        said = (f"{len(messages)} messages, the last one had {len(previous)}: the first {shared} are the same, message {shared + 1} "
                f"({role}) is the same for its first {same} of {len(text)} characters (the last one: {len(previous[shared][1])})")
    last_now, last_before = messages[-1][1], previous[-1][1]
    return (said + f"; the last message is the same as the last one's last message for its first "
            f"{len(os.path.commonprefix([last_before, last_now]))} of {len(last_now)} characters (the last one: {len(last_before)})")


def upstream_model(model):
    """The name the server knows the model by: LiteLLM's name is `openai/<name>`."""
    return model[len("openai/"):] if model and model.startswith("openai/") else model


def server_root(api_base):
    """The slots are at the root of llama-server, the API address of the model ends in /v1."""
    base = api_base.rstrip("/")
    return base[:-len("/v1")] if base.endswith("/v1") else base


class SlotCacheHook(CustomLogger):
    def __init__(self):
        super().__init__()
        self.switchers = {}
        self.loaded = {}            # per server: the models the router has loaded, the one used longest ago first
        self.skipped = set()        # the routes that were reported as not used
        self.last = {}              # the messages of the last prompt of each session, to say where the next one differs
        self.client = httpx.AsyncClient(timeout=None)

    def explain(self, key, messages):
        if not isinstance(messages, list):
            return
        current = [(m.get("role"), text_of(m)) for m in messages if isinstance(m, dict)]
        previous = self.last.get(key)
        self.last[key] = current
        if previous is not None:
            log.info("slot_cache: %s prompt: %s", key, compare(previous, current))

    async def make_room(self, root, model, models_max, api_key):
        """Follows the models the router has loaded; the ones it unloads for `model` get their slot saved first."""
        if not models_max or not model:
            return
        loaded = self.loaded.setdefault(root, collections.OrderedDict())
        loaded[model] = None
        loaded.move_to_end(model)
        while len(loaded) > models_max:
            gone, _ = loaded.popitem(last=False)
            for (r, m, _slot), switcher in list(self.switchers.items()):
                if r == root and m == gone:
                    await switcher.release(api_key)

    # runs after the router chose the deployment (so its api_base is known) and before the request is sent
    async def async_pre_call_deployment_hook(self, kwargs, call_type):
        headers = (kwargs.get("proxy_server_request") or {}).get("headers") or {}
        session = headers.get("x-session-id")
        options = (kwargs.get("model_info") or {}).get("slot_cache")
        api_base = kwargs.get("api_base")
        # the route the client asked for; without one the files of the models could not be told apart
        route = (kwargs.get("metadata") or {}).get("model_group") or kwargs.get("model")
        if not (session and SESSION_ID.match(session)):
            return None
        if not (options and api_base and route):
            # a request of Marginalia that is passed through: say why, once per route
            why = "no model_info.slot_cache" if not options else "no api_base" if not api_base else "no route"
            if (route, why) not in self.skipped:
                self.skipped.add((route, why))
                log.info("slot_cache: not used for %s: %s", route, why)
            return None
        options = options if isinstance(options, dict) else {}
        tag = route_tag(route)
        folder = CacheFolder(options.get("save_path", SAVE_PATH), int(options.get("max_files", MAX_FILES)),
                             int(float(options.get("max_gib", MAX_GIB)) * 1024 ** 3), tag)
        slot = int(options.get("slot", SLOT))
        model = options.get("model") or upstream_model(kwargs.get("model"))
        where = (server_root(api_base), model, slot)
        self.explain(f"{tag}_{session}", kwargs.get("messages"))
        await self.make_room(where[0], model, int(options.get("models_max", MODELS_MAX)), kwargs.get("api_key"))
        if where not in self.switchers:
            self.switchers[where] = SlotSwitcher(where[0], slot, self.client, model)
        await self.switchers[where].switch(f"{tag}_{session}", kwargs.get("api_key"), folder,
                                           float(options.get("save_idle_seconds", SAVE_IDLE_SECONDS)))
        return None


proxy_handler_instance = SlotCacheHook()

and switch it on in the LiteLLM config.yaml: the callback, and slot_cache in the model_info of the route that goes to the llama-server. One wildcard entry is enough, no model has to be listed in LiteLLM (the server knows them, and the client names the one it wants):

model_list:
  - model_name: "*"
    litellm_params:
      model: "openai/*"
      api_base: http://llama-server:8080/v1
      api_key: none
    model_info:
      slot_cache: true
      # or, each one optional:
      # slot_cache: {slot: 0, save_path: /var/cache/llama-slots, max_files: 8, max_gib: 20, models_max: 1, save_idle_seconds: 120}

litellm_settings:
  callbacks: slot_cache.proxy_handler_instance

The hook needs no address of its own: it uses the api_base and api_key of the route the request goes to (the slots are at the root of the server, so a trailing /v1 is dropped). Only the routes with slot_cache are touched; a request for any other model, a cloud one for instance, passes through even though it carries the header. Several servers work side by side, each with its own slot. With the wildcard the route is the model the client asked for, so the files are kept per model (qwen3-32b), and you can still list some models by name next to it if they need other settings. LiteLLM reads the part of a model name before a / as the provider, so for names that contain one (org/model, as in a router preset) use the route model_name: "openai/*" and give Marginalia the model as openai/org/model: the hook removes the openai/, and the files and the slot calls use the name the server knows. The routes and their model_info come from the model_list: a proxy started without one (only litellm --model ...) has neither, and the hook does nothing there. The one wildcard entry is the replacement.

llama-server in router mode (many models behind one address, loaded on demand) works: every model is an instance of its own with its own slots, so the hook keeps the state per server, model and slot, and names the model in the body of its slot calls (the name the client used, without LiteLLM's openai/; the setting model gives another one, for a route whose name is not the model). It needs a llama.cpp from February 2026 or later, which forwards /slots to the instance (before it answered 400 Invalid action).

When the router may hold only some of its models (--models-max, 4 by default), asking for another one makes it unload the model used longest ago, and the slot of that model, with the cache in it, is gone. The hook follows the same order (set models_max in slot_cache, or LLAMA_MODELS_MAX, to the same number as --models-max; 0 for no limit) and saves that slot before the request that makes the router unload it. For that the proxy must be the only client of the router: a request that reaches it another way is not seen. A save never loads a model the router has unloaded. What it does: the cache of a session is written to disk when the slot is needed for another one (a request of another kind, another model, or the router unloading the model), not after every request: a 3 GB write after each generation would only slow the server down. So a file shows up after the first switch, for example generate, annotate, generate: the annotation first saves the generation. With save_idle_seconds in the slot_cache (or LLAMA_SAVE_IDLE_SECONDS) the cache is also written when no request came for that long, so the folder has it soon after you stop working; a request starts the wait over, and a cache that is on disk already is not written again at the next switch. The hook sends a save, restore or erase only when the slot is idle (it asks GET /slots, is_processing; without that endpoint it sends at once): llama-server parks such a call while a request runs and releases only one parked call each time a request ends, so a request of another client that arrived behind it could wait there for good. The server does nothing else while a save writes.

The details: when a request arrives from another session than the one in the slot, the hook saves the slot under the name of the session that was in it, restores the file of the new one (or empties the slot when there is no file yet), and lets the request go on. The files are kept per route (the model_name): a saved state fits only the model that wrote it, so the file is named after the route and the session, and two routes never share one, also when they use the same server and folder. The server then reads only what differs from the restored prompt. A cache that can't be restored, a file that is gone or a server that doesn't answer only means that the prompt is read again; the request itself never fails because of the hook.

Things to know:

  • Size. A file holds the memory of the slot and its context checkpoints, so it is as big as both together: for a long story and a large model hundreds of megabytes to several gigabytes. A measured example: a 31 B model with a prompt of 18,000 tokens made files of 1.3 to 11 GB, the largest with about 18 checkpoints of about 450 MB each besides about 1.5 GB of cache. A server keeps up to 32 checkpoints by default, and every swap writes and reads all of them. --ctx-checkpoints N of llama-server caps that, which makes the files smaller and the swaps shorter, but a checkpoint helps only when the next prompt first differs from the cached one at or just after it. A new part or a swipe differs at the start of the last user message (the newest checkpoint), while an annotation or a redraft can change a part several parts back, so keep as many checkpoints as the story reaches back (look at the prompt: lines of the hook, they tell where each request differs). There is one file per route, user and kind, and each is overwritten in place, so the folder stays as small as that.
  • Speed. Check that a swap is faster than reading the prompt (the hook logs the size and the time of every save and restore). The example above, on an NVMe disk with an AMD Radeon AI PRO R9700: a save of 11 GB took 6 s and a restore of 7.6 GB for 20,000 tokens 8 s, about 14 s for a swap, while reading a prompt of that length again takes about 45 s at 440 tokens per second, and a prefill grows with the prompt. Without checkpoints the same files were about 1 GB and a swap took 5 s.
  • Memory. The checkpoints of the slot are held in the system memory of the server (not in the memory of the GPU), up to --ctx-checkpoints of them (32 by default, hundreds of megabytes each for a large model), and a save or a restore reads or writes all of them. llama-server also keeps the prompts of the slots it gave up in a memory cache of its own (--cache-ram, 8 GiB by default); the files of the hook do the same job, so it can be set to 0 on a machine that is short of memory. When the machine starts to swap, saves and restores become several times slower, as the checkpoints have to be read back from the swap first: look at free -h before looking at the disk.
  • Limits. Without a limit the folder grows with the number of routes, users and kinds. Set max_files (a number of files) and/or max_gib (their total size) in the slot_cache of a route (or LLAMA_CACHE_MAX_FILES and LLAMA_CACHE_MAX_GIB in the environment of the proxy for all routes) and the hook deletes the caches of that route that were used the longest ago after each save: restoring a cache counts as using it. It never deletes the cache it has just saved or the one it is about to restore (so a route with two sessions in turn keeps at least two files, whatever the limit), and it only touches the files of the route, so other routes and other files in the folder are safe. A single cache that is bigger than max_gib stays (with a warning in the log). Deleting needs the folder: set save_path (or LLAMA_SLOT_SAVE_PATH) to the --slot-save-path folder as the proxy sees it; with llama-server in a container, that is a volume both containers mount. Without it nothing is deleted. A deleted cache only means that its session reads its prompt again once. The limits are optional: a cron job on the host does as well when you do not need a cap on the size, for example find /srv/ai/slots -name '*.bin' -mtime +2 -delete (a cache that is in use is saved again whenever its session is swapped out, so its age keeps starting over). Files deleted from outside are harmless to the hook; a missing one is a miss.
  • After you change the model of a route, delete its files in the folder (their names start with the route): a state saved by other weights does not fit the new model.
  • Every switch costs a save and a restore. With automatic annotation on, a part is followed by an annotation and a redraft, so one turn can swap three or four times. Measure a turn before you rely on it.
  • A restart of the proxy forgets which session is in the slot; the first request after it restores its file over the slot, and what the slot held is lost.
  • A model the router unloaded without the hook knowing (a wrong models_max, another client) has an empty slot while the hook still thinks the last session is in it. A request for the same session simply reads its prompt again; a request for another one saves the empty slot over that session's file first, so its cache is lost too. Both only cost a prefill.
  • llama-server does not log a plain save or restore (it logs the context checkpoints it appends or restores, and errors). What it did is in its answer, and the log of the proxy (with LITELLM_LOG=INFO) has it, with a line for every call the hook makes, for example slot_cache: https://… restore <file> (openai/model): ok (21920 tokens, 2580 MB, 3.1 s), … save <file> …: ok (…) or … restore … miss (400); the miss of the first request of a session is normal (llama-server logs it as "Unable to restore slot, no available space in KV cache or invalid slot save file"). Real problems are warnings: a certificate that does not match the address, a refused api key, a server that does not answer.
  • Where the prompt differs. For every request after the first of a session the log has a line that says how far the prompt is the same as the last one of that session, for example slot_cache: <route>_<session> prompt: 14 messages, the last one had 12: the first 11 are the same, message 12 (user) is the same for its first 2150 of 9031 characters. A server can reuse its cache only up to there (a message is about three or four characters per token), so this is the line to read when a second request reads almost everything again: the message it names is the one whose start changed. The line also compares the last message of the two prompts, which holds what changes (the lore, the part, the task): when the story grew by a part, the old last message is not at the same place any more, and the number of characters the two last messages share is how far the lore, for one, is the same in both.
  • If nothing happens, look at the log of the proxy (docker logs litellm-proxy 2>&1 | grep slot_cache): with the hook at work there is a line for every call, and a request of Marginalia for a route without slot_cache is reported once (not used for <route>: no model_info.slot_cache). No slot_cache line at all means the hook did not run: the container still has an older file or config. A single file mounted into a container (./slot_cache.py:/app/slot_cache.py) keeps showing the version it had when the container was created if an editor replaced the file; docker compose up -d --force-recreate picks the new one up (a plain restart does not). docker exec litellm-proxy grep -c save_idle_seconds /app/slot_cache.py prints 0 for a file that is too old.
  • In Docker the proxy and llama-server are two containers, so the folder of --slot-save-path is a directory of the host that both mount (the proxy needs to write there: it deletes old files and renews the age of the used ones), the hook file goes next to the LiteLLM config, and api_base is an address the proxy can reach, with the host name of the certificate when the server uses TLS:

    # llama-server                              # litellm
    volumes:                                    volumes:
      - /srv/ai/slots:/slots                      - ./litellm_config.yml:/app/config.yml:ro
    command: >                                    - ./slot_cache.py:/app/slot_cache.py:ro
      ... -np 1 --models-max 1                    - /srv/ai/slots:/slots
      --slot-save-path /slots

    with save_path: /slots and models_max: 1 in the slot_cache of the route.

  • The hook uses CustomLogger.async_pre_call_deployment_hook, which LiteLLM calls after it chose the deployment and before it sends the request (version 1.106.5 was checked with a real router and a stand-in server); check it against the version you run. A proxy that swaps models behind one address by restarting a single server (llama-swap) is not supported: the slot calls would reach whichever model is loaded. None of this was run against a real llama-server router; the tests use a stand-in that checks the calls the hook makes.