Serve a machine's models from one shared llama-server

A llama.cpp session had its own `llama-server`: two sessions on one model
held two copies of it in memory, a model change bought a load only that
session benefited from, and the process was a session's to end. A machine's
models are now served by one `llama-server` in **router mode** -- no `-m`,
a preset file naming models and their flags, a child server per model asked
for, and each request routed by its `model` field. So one server per model
with that model's own settings is what a machine runs, while this backend
has one process, one port and one record per machine to keep track of.

The record is the mechanism every other driver already uses, so a restart
adopts it; a session records the same pid in its own directory as
`Detail::Shared`, and `process::signal` refuses to signal one of those --
which is what keeps stopping, deleting or cleaning up after one session
from unloading a model every other session is using. Nothing stops a router
on its own. That is deliberate (a loaded model is minutes of disk) and it is
why the machines tab now has a card per provider that opens its own screen:
how each model is loaded, how many stay in memory, Unload, and Stop.

How a model is *loaded* therefore belongs to the model on its machine rather
than to a session -- context size, GPU layers, threads, slots, speculative
decoding -- written into the preset as llama-server's own argument names.
Saving them re-reads that file, which unloads the model; that is the change
taking effect, and the dialog says so before you save. What stays a
session's is everything that rides on a request, including which tools it
offers: the router hosts one set for the machine and the choice is a filter
applied here, so it costs no reload (2,181 tokens of prompt with all seven,
698 with none).

Verified end to end against the scratch backend and the emulator: two
sessions sharing one loaded model with one child process, a second session
joining it with a 26ms prefill, a backend restart adopting the router and
answering with the prompt cache intact, the same over ssh to this VM, a
model's settings reaching the running server, Unload, and Stop leaving every
session `exited` with no error line.
This commit is contained in:
iris-ai committed 2026-09-19 17:37:31 -04:00
1 parent 74cda485e5
commit 8c323fc7a9
19 files changed
+2601 -511

No files matched your search

+95 -13
View File
@@ -338,8 +338,9 @@ thread, never that thread's own assistant reply.
### The llama driver
One `llama-server` per session, started through the same `Transport` as any
other process and then reached over HTTP on a loopback port. Two things are
One `llama-server` per **machine**, in router mode, shared by every session on
it and reached over HTTP on a loopback port through the same `Transport` as
any other process. A session has no process of its own. Two things are
deliberate and easy to undo by accident:
- **The conversation is rebuilt from the transcript**, not kept in the
@@ -347,6 +348,50 @@ deliberate and easy to undo by accident:
when the process restarts. That leaves the Claude driver as the odd one
out rather than this one — the CLI's memory is a cache in front of the same
transcript. Resolve any inconsistency in this direction.
- **The machine's server is shared, outlives this backend, and stops only
when somebody says so** (2026-09-19, `session/llama/router.rs`). It was one
`llama-server` per session until then, which meant two sessions on one model
held two copies of it in memory and a model change bought a load only the
session that asked for it benefited from. `llama-server` started with no
`-m` is a **router**: it reads a preset file naming models and their flags,
starts a child server per model that is asked for, and routes each request
by the `model` field in it. So "one server per model, with that model's own
settings" is what a machine runs, while this backend has one process, one
port and one record to keep track of.
- **The record is the same mechanism every other driver uses**, so a
restart adopts it: `process.json` in the router's own directory beside
the session directories. A session records the *same* pid in its own
directory as a `Detail::Shared`, which is what makes "is the thing I am
talking to still there?" one question with one answer — and `process::
stop` refuses to signal a `Shared` record, so stopping, deleting or
cleaning up after one session cannot take a model out of memory for
every other session on that machine. A flag would have been a rule to
remember in five places; the variant is checked in the one function that
signals.
- **Nothing unloads a model on its own.** The last session closing leaves
it loaded on purpose — the next session to want it would otherwise pay
the load again — so the memory is freed from the machine's provider
settings, where what it costs everybody is visible. `--models-max`
(default 1 here, editable) is the one automatic eviction, and it is LRU:
a machine with one GPU wants the second model to replace the first.
- **How a model is loaded belongs to the model, not to the session**
(`ProviderConfig::model_settings`, `LLAMA_MODEL_PARAMS`). Context size,
GPU layers, threads, slots, speculative decoding: one loaded copy answers
several sessions, so a session cannot own these without one of them being
silently ignored. They are written into the preset file as
`llama-server`'s own argument names, and saving them re-reads that file —
which **unloads** the model if it was loaded. That is the change taking
effect rather than a side effect, and the dialog says so before you save.
What stays the session's is everything that rides on a request:
temperature and the rest, thinking, the permission mode, and which tools
it offers.
- **The preset file lives on the machine that serves the models**, written
over the same transport (base64 through `sh`, so an INI value never
passes through quoting twice). It is read back before every edit rather
than remembered: a router adopted from a previous run is already serving
models whose sections this process has never seen, and rewriting the file
without them would unload them at the next re-read. Only a text that
actually differs is written, for the same reason.
- **A llama session runs on its configured machine** (2026-09-04,
the last of phase 5). A transport is "run this" plus "reach this port", and
the second half is `Transport::reserve_port` — the port the server binds
@@ -388,8 +433,13 @@ deliberate and easy to undo by accident:
will not load, a port already taken, a flag an older build does not know:
all exit within a second and none will ever answer `/health`, so waiting
out the 300s timeout turned the server's own account of the problem into
"gave up". The failure carries the tail of `llama-server.log`, which on a
remote session is the only copy anybody reading the phone can see.
"gave up". The failure carries the tail of `llama-router.log`, which on a
remote machine is the only copy anybody reading the phone can see — the
router's children write into it too, so a model that would not load says
why. A load is two questions since the router arrived: the router
answering at all (30s, because it loads nothing), and the model reaching
`loaded` in `GET /models` (300s, because it is disk). A model the router
reports `unloaded` with an exit code is a failure now rather than a wait.
- **Loading is a state of its own** (2026-09-19, `SessionStatus::Loading`).
A multi-gigabyte model takes tens of seconds to reach memory and refuses
everything until it has, and the session used to report `running` for that
@@ -401,6 +451,16 @@ deliberate and easy to undo by accident:
not ready" is a fact only a driver can have. The third state matters as
much as the first two: a model that will never load has to answer a waiting
message with what went wrong rather than holding it for ever.
- **Which tools a session offers is a filter here, not a flag there**
(2026-09-19). The router is always started with `--tools all` and hosts one
set of tools for the machine — one per session is not a thing a shared
server can have — so the `tools` param picks from the definitions this
backend sends with each request. It costs no reload, and it is worth
choosing: all seven are ~2,000 tokens of every prompt, measured at 2,181
against 698 with none, which on a small context window is the difference
between a usable session and one that overruns. `POST /tools` carries an
`x-tool-cwd` header, which is what lets one shared server run each
session's tools in that session's own directory.
- **The driver runs the agent loop, and therefore owns the permission gate**
(2026-09-19). `llama-server --tools all` *hosts* the built-in tools —
`GET /tools` is their definitions, `POST /tools` runs one — but it does not
@@ -422,15 +482,21 @@ deliberate and easy to undo by accident:
out, not the machine with the GPU. `llama-server`'s own `--mcp-servers-json`
is deliberately not used — it can only spawn local commands, so a remote
server would mean a Node bridge on whichever machine serves the model.
- **A model change reloads the server rather than being refused** (2026-09-19).
A `llama-server` holds one model, so switching stops it and starts another;
the conversation survives because the conversation was never in the server.
What is lost is the prompt cache, which is exactly what the phone already
warns about before a switch.
- **One slot, and the draft head where the file has one** (measured
2026-09-19). `-np 1` always: a session is one conversation making one
request at a time, so the other three slots `llama-server` picks on its own
are context this session could have had. It is also what decides whether
- **A model change asks for another model rather than being refused**
(2026-09-19). Nothing is stopped: the machine's server holds whichever
models it has been asked for, and the one this session is leaving may be
somebody else's. It costs a load where nobody had that model open and a
round trip where somebody did. The conversation survives either way because
it was never in the server. What is lost is the prompt cache, which is
exactly what the phone already warns about before a switch.
- **One slot by default, and the draft head where the file has one**
(measured 2026-09-19). `parallel = 1` unless that model is told otherwise:
a session is one conversation making one request at a time, so the other
three slots `llama-server` picks on its own are context nobody asked for.
Sharing one server makes the number a real choice — a second session's turn
waits behind the first at one slot — which is why it is a per-model setting
rather than a constant, with the trade-off measured below. It is also what
decides whether
multi-token prediction pays — on the 27B here, **41.5 tok/s** plain at any
slot count, **61.4** with `--spec-type draft-mtp` at one slot, and **28**
with the head at four. Speculating against a split KV cache is worse than
@@ -1262,6 +1328,22 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21).
row that has just moved ignores taps for `SETTLE_MS`.
3. **Models** and **Machines** — browsing and downloading GGUFs; adding,
renaming, re-probing and removing machines.
**A provider is a card that opens** (2026-09-19, `ProviderScreen.kt`).
Settings that belong to a *machine* had nowhere to live until one
`llama-server` came to serve every session on one: how each of its models
is loaded, how many it keeps in memory, and the only control that takes a
loaded model out of memory again. So each of a machine's providers is a
card, and tapping it opens that provider on that machine. What it shows is
fetched rather than carried from the card, because a stale copy of it
would be a second version of the same truth.
**Stop is shown and disabled rather than hidden**, with the confirmation
saying plainly what it costs: every model unloaded, every session on that
machine showing as exited, and the next message to one paying the load
again. Hiding the destructive option would not prevent the outcome, only
move it somewhere with no warning attached.
**A model with no status line is one nobody could ask about** — the server
is not running — rather than one that is unloaded. The two are different
facts and only one of them was measured.
4. **Session screen** — the core:
- The transcript rendered from the event stream: markdown, inline images,
tool cards, question cards.