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
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:
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 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 Nofllama-servercaps 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 theprompt: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-checkpointsof 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-serveralso 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 atfree -hbefore 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/ormax_gib(their total size) in theslot_cacheof a route (orLLAMA_CACHE_MAX_FILESandLLAMA_CACHE_MAX_GIBin 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 thanmax_gibstays (with a warning in the log). Deleting needs the folder: setsave_path(orLLAMA_SLOT_SAVE_PATH) to the--slot-save-pathfolder as the proxy sees it; withllama-serverin 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 examplefind /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-serverdoes 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 (withLITELLM_LOG=INFO) has it, with a line for every call the hook makes, for exampleslot_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 withoutslot_cacheis reported once (not used for <route>: no model_info.slot_cache). Noslot_cacheline 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-recreatepicks the new one up (a plain restart does not).docker exec litellm-proxy grep -c save_idle_seconds /app/slot_cache.pyprints 0 for a file that is too old. -
In Docker the proxy and
llama-serverare two containers, so the folder of--slot-save-pathis 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, andapi_baseis 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 /slotswith
save_path: /slotsandmodels_max: 1in theslot_cacheof 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 realllama-serverrouter; the tests use a stand-in that checks the calls the hook makes.