Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1389 lines
60 KiB
Markdown
1389 lines
60 KiB
Markdown
# Installing llmgw from nothing
|
||
|
||
Written for whoever is standing up the cluster: you have servers, you have root
|
||
on them, and nothing is installed yet. For only the commands, read
|
||
[quick-install.md](quick-install.md) first; this page explains them. By the end there is a control plane, a
|
||
node in the fleet, and a model answering requests.
|
||
|
||
It assumes Debian or Ubuntu and adjusts nothing for other distributions beyond
|
||
the package names. Everything here has been done on a real cluster except where
|
||
this page says otherwise, and where it says otherwise it says so plainly.
|
||
|
||
## What llmgw does not install
|
||
|
||
Read this first, because it is the difference between an afternoon and a week.
|
||
|
||
llmgw runs engines and routes requests. It does **not** install GPU drivers,
|
||
CUDA, or the inference engines themselves. A node missing any of them still
|
||
registers, still reports `ready`, and still fails every replica placed on it —
|
||
so the useful order is to make a node able to serve *before* onboarding it, not
|
||
after.
|
||
|
||
| You provide | llmgw provides |
|
||
| --- | --- |
|
||
| NVIDIA driver and `nvidia-smi` | GPU discovery, classes, health |
|
||
| InfiniBand stack (`rdma-core`/OFED) | Fabric discovery and the invariant that a replica never spans one |
|
||
| vLLM, SGLang or llama.cpp | Launching them, the right flags, supervision |
|
||
| Model weights, from somewhere | Fetching them, placement, routing, accounting |
|
||
|
||
The reason is boring and unavoidable: drivers and engines are platform decisions
|
||
with kernel modules and version matrices attached.
|
||
|
||
Weights are the one item on that list llmgw does handle. It includes a mirror —
|
||
step 3 — which holds the files on your own network and hands them to the nodes,
|
||
so getting weights onto every machine is not a problem you solve before
|
||
starting. What llmgw will not do is choose the models for you.
|
||
|
||
## 0. Before you begin
|
||
|
||
Four machines is a reasonable first cluster: one control plane and three nodes.
|
||
One of each also works.
|
||
|
||
**The control plane** needs no GPU. It is a single process holding a SQLite
|
||
file; 2 GB of RAM and a few gigabytes of disk is generous. It must be reachable
|
||
from every node on the agent port, and from your users on the client port.
|
||
|
||
**Nodes** need whatever the model needs, plus about 8 GB for the operating
|
||
system and the agent. They dial the control plane; the control plane never dials
|
||
them, so nodes need no inbound access except your own SSH.
|
||
|
||
**Ports**
|
||
|
||
| Port | Who connects | Expose to |
|
||
| --- | --- | --- |
|
||
| 8080 | clients, the web UI | your users, usually via a reverse proxy |
|
||
| 7443 | node agents | the node network only |
|
||
| 9090 | `llmgw ctl`, the management API, pprof | loopback only — it serves pprof, which can dump the process heap, and API keys live in that heap |
|
||
| 9091 | Prometheus, if you run it | your monitoring host; metrics only, nothing else is served here |
|
||
| 8081 | node agents fetching weights | the node network only (the mirror machine) |
|
||
| 8082 | the control plane administering the mirror | the control plane only — this port can delete models and start downloads |
|
||
|
||
Decide now which address nodes will use to reach the control plane. It goes into
|
||
a certificate: the machine's own addresses are found for you, and any other name
|
||
— a DNS alias, a NAT or floating address — has to be given with `--host`.
|
||
|
||
## 1. The binary
|
||
|
||
One static binary, no runtime dependencies, the same on every machine.
|
||
|
||
```
|
||
VERSION=v0.8.0 # check the releases page for the current tag
|
||
curl -fsSLO https://gitea.mynodes.xyz/ilker/llmgw/releases/download/$VERSION/llmgw-$VERSION-linux-amd64
|
||
curl -fsSL https://gitea.mynodes.xyz/ilker/llmgw/releases/download/$VERSION/checksums.txt | sha256sum -c --ignore-missing
|
||
sudo install -m0755 llmgw-$VERSION-linux-amd64 /usr/local/bin/llmgw
|
||
llmgw --version
|
||
```
|
||
|
||
`arm64` and `darwin` builds are published alongside. They are built with
|
||
`CGO_ENABLED=0` deliberately, so a binary built today runs on a node whose libc
|
||
is older than the builder's.
|
||
|
||
To build instead of download, you need Go 1.26.6 or later and nothing else (an older Go fetches it itself):
|
||
|
||
```
|
||
git clone https://gitea.mynodes.xyz/ilker/llmgw && cd llmgw
|
||
CGO_ENABLED=0 go build -o llmgw ./cmd/llmgw
|
||
```
|
||
|
||
## 2. The control plane
|
||
|
||
**The short way is the screens.** On the machine that will be the control
|
||
plane, run `sudo llmgw` with nothing after it. With nothing installed it asks
|
||
whether this is one machine or three for high availability; for three, it asks
|
||
for the other two — address, user, and an ssh key, the ssh agent, or a
|
||
password, and the sudo password if sudo asks for one; or, for a machine
|
||
nothing may log into, a join code to run there with `sudo llmgw join <code>`,
|
||
which dials out to this one on port 7444 — checks all three before
|
||
installing anything (system, room, clock, ports, internet, whether they reach
|
||
one another), shows the plan, and installs. Passwords are held in memory for
|
||
the run only; a sudo password lets the user sudo without one until the install
|
||
finishes, and is then taken back. At the end it shows the administrator's
|
||
password and the command that does the same from a script.
|
||
|
||
Run again later, `sudo llmgw` is the console: the cluster's state, refreshed,
|
||
and what to do next — add a node, replicate the store. A setup that stopped
|
||
halfway opens the setup screens again, which finish it.
|
||
|
||
The rest of this section is the same by command, as root:
|
||
|
||
```
|
||
sudo llmgw server init --admin you@example.com
|
||
```
|
||
|
||
That is the whole step. In order, it:
|
||
|
||
1. creates the `llmgw` system user, `/etc/llmgw` and `/var/lib/llmgw`
|
||
2. creates the cluster's certificate authority in `/etc/llmgw/pki`
|
||
3. issues the control plane's certificate for this machine's hostname and
|
||
every address it has, and copies `ca.pem` beside it
|
||
4. writes `/etc/llmgw/server.yaml`: mutual TLS, authentication, default limits
|
||
and a price, and a control plane with an empty catalog
|
||
5. creates the store, the `default` project, and your account, already approved
|
||
and promoted to administrator — the password is generated and **printed
|
||
once**
|
||
6. installs the systemd unit, starts it, and waits until `/health` answers
|
||
7. runs a model mirror beside it (`/srv/models`, bytes on `:8081`, its
|
||
management API on loopback) and points the control plane at it
|
||
8. makes this machine a node of its own control plane — on its GPUs when
|
||
`nvidia-smi` finds any, on CPU otherwise — and waits for it to install its
|
||
engine
|
||
9. fetches Qwen2.5 0.5B into the mirror and serves it on this node, so the
|
||
first thing anyone opens `/chat` to find is a model that answers
|
||
|
||
`--no-mirror`, `--no-node` and `--no-model` leave the last three out. An NVIDIA
|
||
card with no working driver is reported rather than silently ignored; install
|
||
the driver and run init again to serve on it.
|
||
|
||
Add `--postgres` to keep accounts and keys in PostgreSQL rather than SQLite:
|
||
init installs it and sets it up, and on a machine already running on SQLite it
|
||
moves everything across. See [PostgreSQL](#postgresql-when-sqlite-is-no-longer-the-right-shape).
|
||
|
||
Or `--patroni`, given two machines (or more), to keep it in PostgreSQL
|
||
replicated across them, so losing one loses nothing:
|
||
|
||
```
|
||
sudo --preserve-env=SSH_AUTH_SOCK llmgw server init --patroni ubuntu@10.0.0.149 --patroni ubuntu@10.0.0.150
|
||
```
|
||
|
||
Over ssh with passwordless sudo, as `node add` works, it installs PostgreSQL,
|
||
Patroni and etcd there — this machine is etcd's third vote — and moves the
|
||
store already in use, SQLite or PostgreSQL, into the cluster. A standby is
|
||
promoted within about thirty seconds of the primary being lost, and the
|
||
control plane follows it by itself. `llmgw ctl status` says who leads; see
|
||
[deploy/ha](../deploy/ha/README.md) for what it sets up.
|
||
|
||
Every step looks before it acts. Running it again finishes an install that
|
||
stopped halfway and changes nothing on one that did not; it never overwrites
|
||
`server.yaml`, the authority, or an existing account's password.
|
||
|
||
**The addresses in the certificate matter.** Agents check that the address
|
||
they dial is named in it, and the certificate names only what this machine
|
||
could see of itself. If nodes will reach it by another name — a DNS alias, a
|
||
NAT address, a floating IP — give it:
|
||
|
||
```
|
||
sudo llmgw server init --host llmgw-ctl.internal --host 203.0.113.10
|
||
```
|
||
|
||
Run that at any time: the certificate is reissued with the new names added to
|
||
the old ones, and the service restarted.
|
||
|
||
**`ca-key.pem` signs every node's identity.** `init` leaves it in
|
||
`/etc/llmgw/pki`, because onboarding a node needs it and the control plane is
|
||
where most people run onboarding from. Back it up; there is no recovering it.
|
||
If this machine is exposed, move the directory elsewhere once the nodes are in
|
||
and pass `--pki` to onboarding.
|
||
|
||
This covers the fleet-facing port only. The client-facing API is public and
|
||
belongs behind an ordinary publicly trusted certificate, terminated by a
|
||
reverse proxy in front of port 8080. [deploy/proxy/](../deploy/proxy/) has a
|
||
Caddyfile and an nginx configuration that get streaming, timeouts and the
|
||
forwarded headers right; both are attached to every release.
|
||
|
||
### Check it
|
||
|
||
```
|
||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/health # 200
|
||
sudo llmgw ctl status
|
||
```
|
||
|
||
`ctl status` will report no nodes and no models, which is correct — nothing has
|
||
been added yet. Sign in at `http://<control-plane>:8080/login` with the printed
|
||
password, change it, and issue yourself a key from the profile page.
|
||
|
||
### The configuration
|
||
|
||
`/etc/llmgw/server.yaml` is yours from here on. Three things in it are worth
|
||
knowing before you edit it:
|
||
|
||
- `admin_listen` is on loopback on purpose, and the server refuses any other
|
||
address unless `admin_remote: true`. Its API under `/api` adds models and
|
||
upgrades nodes, so it also wants a token: the server writes one, 0600, to
|
||
`/var/lib/llmgw/admin-token` the first time it starts, and `llmgw ctl`
|
||
reads it from there — which is why `llmgw ctl` runs with sudo.
|
||
`LLMGW_ADMIN_TOKEN` gives it from elsewhere. pprof, whose heap holds the API
|
||
keys requests presented, is off unless `debug_pprof: true`, and then behind
|
||
the token too. To see the dashboard from elsewhere use `/admin` on the
|
||
client port — do not move this listener.
|
||
- `default_rpm`, `default_tpm` and `default_price` are what make limits and
|
||
budgets mean anything. Without a price every request costs zero and a budget
|
||
never binds; without the defaults, a project created without limits is
|
||
metered by nothing at all.
|
||
- `control.enabled: true` is what makes this a control plane while its catalog
|
||
is empty. Without it, a file with no models is refused as having nothing to
|
||
serve.
|
||
|
||
Every key is in [reference.md](reference.md).
|
||
|
||
### Keeping the authority off the control plane
|
||
|
||
Create it on another machine, issue the control plane's certificate there, and
|
||
bring only the results:
|
||
|
||
```
|
||
# on the authority's machine
|
||
llmgw ctl cert init-ca --dir /etc/llmgw/pki
|
||
llmgw ctl cert issue-server llmgw-ctl --host 10.0.0.140 --host llmgw-ctl.internal \
|
||
--dir /etc/llmgw/pki --out ./ctl
|
||
|
||
# copy ca.pem, ctl/server.pem and ctl/server-key.pem into /etc/llmgw on the
|
||
# control plane, then there:
|
||
sudo llmgw server init --admin you@example.com
|
||
```
|
||
|
||
`init` finds certificates in `/etc/llmgw` and no authority beside them, and
|
||
keeps them as they are. Adding an address later is then done where the
|
||
authority is; `init --host` says so rather than minting a second authority no
|
||
node trusts.
|
||
|
||
## 3. The mirror
|
||
|
||
Do this before the nodes, not after. Every node fetches its own copy of a
|
||
model's weights, and where they fetch from decides whether bringing up a cluster
|
||
takes minutes or a day. Eight nodes pulling forty gigabytes each from Hugging
|
||
Face is a day's work, and on a good number of corporate networks it is not
|
||
possible at all — the nodes have no route out, which is usually the point.
|
||
|
||
The mirror is one machine holding the files on your own network. It is the same
|
||
binary again, and it is the highest-value piece of infrastructure around llmgw
|
||
for the least work.
|
||
|
||
### Running it
|
||
|
||
```
|
||
sudo install -d -o llmgw -g llmgw /srv/models
|
||
llmgw mirror serve --dir /srv/models --base http://models.internal:8081
|
||
```
|
||
|
||
In production, the unit:
|
||
|
||
```
|
||
sudo cp deploy/systemd/llmgw-mirror.service /etc/systemd/system/
|
||
sudo systemctl daemon-reload && sudo systemctl enable --now llmgw-mirror
|
||
```
|
||
|
||
**Two listeners, on purpose.** `:8081` serves bytes and nothing else — that is
|
||
what every agent talks to, and it is plain HTTP because a weights fetch is a
|
||
public file transfer that is checksummed anyway. `:8082` is the management API:
|
||
it can start downloads and delete models, and only the control plane needs it.
|
||
The split is what lets you lock the second one down without re-certifying every
|
||
agent that fetches from the first.
|
||
|
||
Give the management port the cluster's own CA and it takes client certificates —
|
||
the control plane's, named with `--api-client`, and no other. The same CA signs
|
||
every node's certificate, so the name is required: without it any node, a
|
||
removed one's included, could delete models.
|
||
|
||
```
|
||
llmgw mirror serve --dir /srv/models --base http://models.internal:8081 \
|
||
--tls-ca /etc/llmgw/ca.pem \
|
||
--tls-cert /etc/llmgw/mirror.pem --tls-key /etc/llmgw/mirror-key.pem \
|
||
--api-client control-plane
|
||
```
|
||
|
||
Without those flags the management port answers this machine only, and the
|
||
addresses given with `--api-allow`.
|
||
|
||
### The layout
|
||
|
||
```
|
||
/srv/models/
|
||
qwen2.5-14b-instruct/
|
||
2026-03/
|
||
q4_k_m.gguf
|
||
```
|
||
|
||
Model, then version, then files. The version directory is the point of the
|
||
layout rather than decoration: a repository that changes upstream changes what
|
||
you are running, and the first sign is a prompt that worked last week answering
|
||
differently this week with nobody able to say why. Pin it here and that cannot
|
||
happen.
|
||
|
||
A version holding several files is a sharded model, and is served as a set: the
|
||
agent asks the directory what it consists of and fetches all of it into one
|
||
place, marking it complete only when the last file lands. A catalogue entry for
|
||
one names the directory, trailing slash included.
|
||
|
||
### Telling the control plane about it
|
||
|
||
```yaml
|
||
# /etc/llmgw/server.yaml
|
||
mirror:
|
||
api: https://models.internal:8082
|
||
tls:
|
||
ca: /etc/llmgw/ca.pem
|
||
cert: /etc/llmgw/server.pem
|
||
key: /etc/llmgw/server-key.pem
|
||
```
|
||
|
||
With this set, `/admin/mirror` appears: what the mirror holds, what it is
|
||
downloading and how far along, a search of Hugging Face, and a form that puts a
|
||
mirrored model into service without anyone writing YAML. Without it that page
|
||
stays unmounted and the mirror is still perfectly usable — a model's `source` is
|
||
just a URL, and the URL can be yours.
|
||
|
||
If the control plane cannot reach the internet, `mirror.registry` points the
|
||
search at an internal registry mirror instead.
|
||
|
||
### Filling it
|
||
|
||
```
|
||
llmgw mirror fetch --model qwen2.5-14b-instruct --version 2026-03 \
|
||
hf:Qwen/Qwen2.5-14B-Instruct-GGUF/qwen2.5-14b-instruct-q4_k_m.gguf
|
||
```
|
||
|
||
or from the admin page, or by copying files into the directory by hand — the
|
||
mirror is a directory, and anything that puts files in it works.
|
||
|
||
## 4. Preparing a node
|
||
|
||
**Do this before onboarding.** A node missing its driver or engine registers
|
||
healthy and fails on first placement, and the reason is three logs away.
|
||
|
||
### The driver
|
||
|
||
```
|
||
sudo apt-get update
|
||
sudo apt-get install -y nvidia-driver-550 # or whatever your cards need
|
||
sudo reboot
|
||
nvidia-smi # must print your GPUs
|
||
```
|
||
|
||
`nvidia-smi` must be on `PATH` for the user the agent runs as, which is root.
|
||
Containerised driver installs often put it elsewhere; if so, note the path and
|
||
set `discovery.nvidia_smi` in the agent configuration rather than moving it.
|
||
|
||
A node with no GPU at all is fine and is not an error. It reports zero cards and
|
||
can host CPU profiles, which is a good way to try llmgw before committing
|
||
hardware to it.
|
||
|
||
### NVLink, if your GPUs have it
|
||
|
||
The agent reads the topology from `nvidia-smi topo -m`: which cards NVLink
|
||
joins, each card's NUMA node, and which network adapter is nearest each. A
|
||
tensor-parallel group is then the set of cards with the most NVLink between
|
||
them — on a PCIe box where bridges pair specific cards, a group straddling two
|
||
bridges falls back to PCIe and runs several times slower, and nothing else
|
||
would report it.
|
||
|
||
On an NVSwitch machine, `nvidia-fabricmanager` has to be running, at the
|
||
driver's version, or CUDA will not start on its GPUs. On an NVL72 rack or an
|
||
NVLink Switch system, the GPUs of several nodes form one domain, and a replica
|
||
can run tensor parallelism across its nodes — which needs `nvidia-imex` and an
|
||
IMEX channel as well. [fabrics.md](fabrics.md) has the list, and `llmgw doctor`
|
||
checks it:
|
||
|
||
```
|
||
sudo llmgw doctor
|
||
```
|
||
|
||
A `-nvml` build is still published beside the portable one; it reads NVLink
|
||
from NVML directly, and needs a libc no older than the builder's.
|
||
|
||
### InfiniBand, if you have it
|
||
|
||
```
|
||
sudo apt-get install -y rdma-core ibverbs-utils infiniband-diags
|
||
ibstat # must list your HCAs with State: Active
|
||
ls /sys/class/infiniband
|
||
```
|
||
|
||
Each fabric needs a subnet manager, and should have a standby; `nvidia_peermem`
|
||
should be loaded for GPUDirect RDMA. You name each fabric when you onboard the
|
||
node, and llmgw checks the name against what the adapters report: every port on
|
||
one fabric sees the same subnet managers, so two independent fabrics given one
|
||
name are placed as two, and the cluster page says so. An adapter that is not
|
||
there, or whose port goes down, takes that fabric off the node within seconds,
|
||
and gives it back when the port returns.
|
||
|
||
[fabrics.md](fabrics.md) covers declaring several fabrics, what the cluster and
|
||
fabric pages show, and what to look at when a replica spanning nodes will not
|
||
run. Run `llmgw doctor` on the node before it joins.
|
||
|
||
### Engines
|
||
|
||
Nothing to do: the agent installs them once the node joins, under
|
||
`/opt/llmgw/engines`, and checks each one runs before the node offers it.
|
||
|
||
| Engine | On | How |
|
||
|---|---|---|
|
||
| llama.cpp | every node | the release build for its processor and GPU — CUDA 13.3, CUDA 12.8 (x64), or Vulkan for GPUs and drivers the CUDA builds do not cover; the CPU build only where there is no NVIDIA GPU — checked against GitHub's checksum and seen to list the GPU |
|
||
| vLLM | nodes with an NVIDIA GPU it can run on | a Python environment built by uv from PyPI, about 8 GB |
|
||
| SGLang | only when you ask for it | the same way as vLLM |
|
||
|
||
vLLM is resolved as PyPI stood a day after that vLLM was published, so every
|
||
node gets the same dependencies whenever it is built, and installed with
|
||
`--require-hashes` against the sha256 PyPI publishes. The resolved list stays in
|
||
the environment as `llmgw-requirements.txt`. It needs:
|
||
|
||
- **the build its hardware runs**, chosen by the node. Driver 580 and newer:
|
||
the CUDA 13 build from PyPI. Driver 525 to 579: the same version built for
|
||
CUDA 12.9, from vLLM's GitHub release (checked against the digest GitHub
|
||
publishes) with PyTorch's CUDA 12.9 torch. Both have kernels for Turing
|
||
(7.5) and newer, 8.0 and newer on aarch64. **A V100** (Volta, 7.0) gets
|
||
**vLLM 0.19.1**, the newest built for it — no build from 0.20 on has its
|
||
kernels — and the cluster page shows it running that version; run `llmgw
|
||
ctl engine-check vllm` there once, since llmgw's flags are checked against
|
||
the current one. Pascal and older run no vLLM. Whatever is chosen, the
|
||
install ends by computing something on the GPU; a build that cannot is
|
||
passed over for the next;
|
||
- **about 25 GB free** under the engines directory while it installs;
|
||
- **gcc**, which Triton builds its kernel launcher with. The agent installs it.
|
||
|
||
A node with no GPU skips vLLM and says nothing about it. A node that has a GPU
|
||
but cannot run it — an old driver, a V100, a small disk — says why on the
|
||
cluster page. `llmgw doctor` checks the same before the node joins.
|
||
|
||
The versions are the control plane's, in `server.yaml`:
|
||
|
||
```yaml
|
||
engines:
|
||
vllm: "0.30.0" # the default; "" leaves vLLM to you
|
||
sglang: "0.5.20" # SGLang only when named
|
||
```
|
||
|
||
A changed version reaches every node when it next connects. To use an engine
|
||
you installed yourself on a node, name its path in that node's `agent.yaml`
|
||
(`engines.llamacpp.bin`, `engines.vllm.bin`, `engines.sglang.python`): the agent
|
||
then installs none of its own beside it.
|
||
|
||
#### Choosing a node's vLLM build
|
||
|
||
Which build a GPU node installs is chosen on the cluster page, in the **vLLM
|
||
build** column — *auto* (what its hardware runs, the default), *CUDA 13*,
|
||
*CUDA 12.9*, or *none* — or from the command line:
|
||
|
||
```
|
||
llmgw ctl engine-build gpu07 vllm cu129
|
||
```
|
||
|
||
The choice is kept by the control plane and the node is told at once; it
|
||
installs the build in the background, and the column shows how that stands.
|
||
|
||
#### Different GPUs, different engines
|
||
|
||
A cluster of two GPU generations may need two builds of an engine, or one on
|
||
only some nodes. `engine_overrides` picks nodes — by a GPU class they have, by
|
||
id, or by labels, every one given having to match — and lays engines over the
|
||
cluster's for them, in order:
|
||
|
||
```yaml
|
||
engines:
|
||
vllm: "0.30.0" # CUDA 13: Turing and newer
|
||
engine_overrides:
|
||
- gpu_class: a10g # pin a class to a version you have tested
|
||
engines: { vllm: "0.29.0" }
|
||
- labels: { role: gguf } # nodes meant for llama.cpp alone
|
||
engines: { vllm: "" } # none
|
||
```
|
||
|
||
A node told nothing it can run of an engine joins without it: `llmgw doctor`
|
||
and `node add` warn, and the node serves what it does run. To keep an engine
|
||
off a node for good, whatever the control plane says, add the node with
|
||
`llmgw node add --disable-engine vllm`, which writes `engines.disabled: [vllm]`
|
||
into its `agent.yaml` and leaves vLLM out of doctor's checks. That is narrower
|
||
than `--ignore-checks`, which passes over every other problem as well.
|
||
|
||
A llama.cpp CUDA build is checked for the GPU it is meant for: installing it
|
||
asks it which devices it sees, and a replica placed on GPUs is not ready until
|
||
its process holds memory on each of them. A build missing a CUDA library fails
|
||
there, naming the library, rather than serving on the CPU.
|
||
|
||
After changing a version, check it still takes the flags llmgw builds for it,
|
||
which drift between releases:
|
||
|
||
```
|
||
llmgw ctl engine-check vllm --bin /opt/llmgw/engines/vllm/0.30.0-cu13/bin/vllm
|
||
```
|
||
|
||
### Weights
|
||
|
||
Nothing to do here if you built the mirror in step 3 — the agent fetches what a
|
||
model's `source` names, and that source is your mirror. This section is for the
|
||
arrangements where the files are already on the machine.
|
||
|
||
Put them where the node can read them. `scp`, a shared filesystem, or the
|
||
engine's own downloader are all valid, and which you want depends on how many
|
||
nodes you have.
|
||
|
||
```
|
||
sudo install -d -o root -g root /var/lib/llmgw-models
|
||
# then place your .gguf or model directory there
|
||
```
|
||
|
||
For a first model, something small: a 3B at `q4_k_m` is about 2 GB, runs on
|
||
CPU, and answers fast enough to prove the whole path works.
|
||
|
||
### Letting llmgw fetch them
|
||
|
||
The simple arrangement, and the default: every node keeps its own copy. Point a
|
||
model's `source` at a file to download and the agent fetches it once, before the
|
||
engine is launched:
|
||
|
||
```yaml
|
||
# your mirror, which is what step 3 was for
|
||
source: http://models.internal:8081/qwen2.5-coder-3b-instruct/2026-03/q4_k_m.gguf
|
||
# a whole version, for a model of several files — the trailing slash is the signal
|
||
source: http://models.internal:8081/llama-3.1-70b-instruct/2026-03/
|
||
# or straight from Hugging Face, where the nodes can reach it
|
||
source: hf:Qwen/Qwen2.5-Coder-3B-Instruct-GGUF/qwen2.5-coder-3b-instruct-q4_k_m.gguf
|
||
```
|
||
|
||
A fetch reports its progress to the control plane, so `llmgw ctl replicas` and
|
||
the dashboard both show how far along a node is. A replica pulling a hundred
|
||
gigabytes and one that is wedged do not look alike.
|
||
|
||
Eight shards of one replica starting together produce one download, not eight.
|
||
An interrupted fetch resumes rather than starting over, and a partial file is
|
||
never visible under the model's name — so a half-downloaded model cannot be
|
||
picked up by the next scan and handed to an engine.
|
||
|
||
Three kinds of source, and only one of them is fetched:
|
||
|
||
| `source` | What happens |
|
||
| --- | --- |
|
||
| `/var/lib/llmgw-models/x.gguf` | Checked, never copied. However it got there. |
|
||
| `hf:org/repo/file.gguf`, `https://…` | Fetched once into the cache, then reused |
|
||
| `Qwen/Qwen2.5-7B-Instruct` | Left alone — vLLM and SGLang download repositories themselves |
|
||
|
||
Which means the exotic arrangements need no configuration at all. Weights on a
|
||
Lustre mount, or an image exported over the fabric with SRP or NVMe-oF, are
|
||
simply *present*, and present means nothing is fetched.
|
||
|
||
### Telling llmgw where they are
|
||
|
||
Optional, and unset by default. Naming the directories in the agent's
|
||
`model_dirs` lets a node report which models it can see, and placement then
|
||
prefers a node that already holds one over a node that must first move tens of
|
||
gigabytes:
|
||
|
||
```yaml
|
||
model_dirs:
|
||
- /var/lib/llmgw-models
|
||
- /mnt/lustre/models
|
||
```
|
||
|
||
llmgw does not manage that storage and does not care how the files arrived. A
|
||
Lustre or NFS mount, an image exported over the fabric with SRP or NVMe-oF,
|
||
local NVMe, or an `scp` all look identical to it. A node with nothing configured
|
||
reports nothing and is treated exactly as every node was before this existed.
|
||
|
||
**It matters most when your fabrics are not connected to each other.** Weights
|
||
in `ib-a` are not weights in `ib-b`: the only path between them is the
|
||
management network, which is the slow one. `llmgw ctl status` shows what each
|
||
fabric holds, so a model seeded into one fabric does not quietly leave the rest
|
||
of the cluster unable to serve it.
|
||
|
||
How much disk a node needs follows from the models rather than a rule of thumb.
|
||
`llmgw ctl model plan <repo>` prints the sizes; as a starting point, a 70B at
|
||
bf16 is 141 GB and the same model at q4 is 43 GB.
|
||
|
||
## 5. Adding the node
|
||
|
||
On the control plane, which holds the certificate authority:
|
||
|
||
```
|
||
sudo llmgw node add ubuntu@10.0.0.141 --fabric ib-a:mlx5_0,mlx5_1
|
||
```
|
||
|
||
It logs in to the machine over SSH as that user, who needs passwordless sudo
|
||
there, and:
|
||
|
||
1. installs llmgw: the release for the machine's architecture, downloaded by
|
||
the machine and checked against the release's checksums, or this machine's
|
||
copy when it cannot download;
|
||
2. runs `llmgw doctor` there and stops on any problem, printing the fix. Run it
|
||
again once fixed, or add `--ignore-checks`;
|
||
3. issues the node's certificate and installs it, with the cluster's root,
|
||
trusted system-wide;
|
||
4. writes `agent.yaml` — only what the node cannot find for itself — and the
|
||
systemd unit, and starts the agent;
|
||
5. waits for the node to join and to install llama.cpp (`--wait` also waits
|
||
for vLLM, `--no-wait` for neither).
|
||
|
||
In a terminal it draws this as a checklist and asks what a script cannot: which
|
||
fabric each group of the node's active adapters is on, when `--fabric` was not
|
||
given, and whether to go on past a problem doctor found.
|
||
|
||
**The node id is the machine's hostname**, and the address its agent dials is
|
||
this machine's on the route to it; `--node` and `--server` override them. The
|
||
VM name, the hostname, the node id and the certificate's common name being the
|
||
same string saves an afternoon each time they would differ.
|
||
|
||
**SSH runs as root under sudo**, with root's keys. To use yours, log in to the
|
||
control plane with `ssh -A` and run `sudo --preserve-env=SSH_AUTH_SOCK llmgw node
|
||
add …`. It waits for SSH rather than failing on it, and learns the host key of
|
||
a machine it has not seen — a new machine spends its first minute regenerating
|
||
host keys and restarting sshd.
|
||
|
||
Running it again on a node is safe: a fresh certificate and configuration, and
|
||
the engines it runs are adopted by the restarted agent.
|
||
|
||
Within a few seconds:
|
||
|
||
```
|
||
llmgw ctl nodes
|
||
```
|
||
|
||
```
|
||
NODE STATE GPUS MEM CLASSES FABRICS REPLICAS LAST SEEN
|
||
llmgw-node01 ready 8/8 free 503G h100-80g ib-a 0 2s
|
||
```
|
||
|
||
If it does not appear, the control plane's log says why. A certificate whose
|
||
common name does not match the claimed node id is refused by design — that check
|
||
is what makes a node's identity something it proves rather than something it
|
||
picks.
|
||
|
||
Repeat for each node. Nodes in the same fabric must be given the same `--fabric`
|
||
name; nodes with no InfiniBand take no `--fabric` at all and are each their own
|
||
island, which is fine for single-node models.
|
||
|
||
## 6. Setting the node's CPU model, if it is a VM
|
||
|
||
Proxmox defaults a VM to `kvm64`, which hides AVX, AVX2, FMA and even SSE4.2
|
||
from the guest. Inference then falls back to scalar code and is not slightly
|
||
slower but unusable. Measured on one 8-core VM, same weights, same threads, the
|
||
only change being `--cpu host`:
|
||
|
||
| Qwen2.5-Coder | kvm64 | host |
|
||
| --- | ---: | ---: |
|
||
| 1.5B q4_K_M | 5.80 | **39.09** tok/s |
|
||
| 3B q4_K_M | 2.90 | **20.51** |
|
||
| 7B q4_K_M | 1.33 | **9.88** |
|
||
|
||
`llmgw-provision-node` sets `--cpu host` for you. A VM you built yourself
|
||
probably did not, and nothing about it looks broken — every number is simply
|
||
small. Check with `grep -qw avx2 /proc/cpuinfo` on the node.
|
||
|
||
## 7. Adding a model
|
||
|
||
Write the definition. This is the same shape a model takes in `server.yaml`, so
|
||
an entry moves between the two by being copied:
|
||
|
||
```yaml
|
||
# qwen-coder-3b.yaml
|
||
name: qwen2.5-coder-3b-instruct
|
||
aliases: [coder]
|
||
source: http://models.internal:8081/qwen2.5-coder-3b-instruct/2026-03/q4_k_m.gguf
|
||
routing_profile: chat
|
||
# What a client may use in one request. Worth stating: llama.cpp divides
|
||
# --ctx-size across its parallel slots, so a pool of 65536 over two slots is
|
||
# 32768 each, and nothing else will tell you that.
|
||
context_length: 32768
|
||
max_output: 4096
|
||
scaling: { min: 1, max: 1 }
|
||
profiles:
|
||
- name: cpu
|
||
engine: llamacpp
|
||
weight: 1
|
||
priority: 100
|
||
startup_timeout: 5m
|
||
min_memory_gb: 8
|
||
args: ["--ctx-size", "65536"]
|
||
```
|
||
|
||
```
|
||
llmgw ctl model add -f qwen-coder-3b.yaml
|
||
llmgw ctl models
|
||
```
|
||
|
||
Adding a model checks that the source is actually there before accepting it, so
|
||
a typo or a mirror that is down is a sentence now rather than a replica failing
|
||
on a node in five minutes with an error naming the node. `--no-source-check`
|
||
skips it, which is right when the nodes reach the mirror by an address this
|
||
machine does not.
|
||
|
||
With a mirror configured you can skip the file entirely: `/admin/mirror` lists
|
||
what the mirror holds and offers a form that writes the same definition, with
|
||
the engine, the source and the memory figure already filled in from the file
|
||
itself.
|
||
|
||
```
|
||
MODEL SERVING REPLICAS STATUS ALIASES
|
||
qwen2.5-coder-3b-instruct yes 1/1 ready coder
|
||
```
|
||
|
||
No restart, and it survives one: models added this way are stored, while
|
||
anything in `server.yaml` is declarative and returns on every start.
|
||
|
||
### Models that load when someone asks
|
||
|
||
A cluster with more models than GPUs can hold every model in its catalog and
|
||
only the wanted ones in its cards:
|
||
|
||
```yaml
|
||
scaling: { min: 0, max: 1, idle_timeout: 15m }
|
||
```
|
||
|
||
`min: 0` with a `max` above it means load on demand. The first request for the
|
||
model starts it and waits briefly; if it is not serving by then the client gets
|
||
a 503 with `Retry-After` and an honest "the model is loading", rather than being
|
||
told nothing serves it — a client told to retry succeeds where one told the
|
||
model does not exist gives up. Once nobody has asked for it in `idle_timeout`,
|
||
the replicas are stopped and the cards go back to the pool.
|
||
|
||
`min: 0, max: 0` is different and equally useful: configured, and deliberately
|
||
not running. A model with no `scaling` stanza at all gets that, because making
|
||
every unscaled model loadable on demand would let any client request start a
|
||
70B nobody intended to run.
|
||
|
||
Two things to weigh. A cold start is visible to whoever triggers it, so this
|
||
suits the long tail people are experimenting with rather than the two or three
|
||
models used daily — keep those at `min: 1`. And stopping a replica throws away
|
||
the KV cache prefix routing spent its effort filling, so an idle timeout wants
|
||
to be minutes rather than seconds.
|
||
|
||
When the cluster is full, a model somebody asks for will stop an idle one to
|
||
make room. Only idle replicas of other on-demand models: a model at `min: 1` is
|
||
a promise that it stays up, and breaking that to serve a different request would
|
||
make every guarantee in the catalog conditional on load. Only single-node
|
||
replicas, because evicting a multi-node one frees several machines and costs
|
||
tens of minutes to rebuild — a decision worth making deliberately. One at a
|
||
time. And never a replica that has just started, which is what stops two models
|
||
on a cluster with room for one from taking turns evicting each other.
|
||
|
||
For a GPU model, name the class and the width instead:
|
||
|
||
```yaml
|
||
profiles:
|
||
- name: tp2
|
||
engine: vllm
|
||
gpu_class: h100-80g # as `llmgw ctl nodes` reports it
|
||
tp: 2
|
||
priority: 100
|
||
```
|
||
|
||
If it does not place, ask:
|
||
|
||
```
|
||
llmgw ctl explain-placement qwen2.5-coder-3b-instruct
|
||
```
|
||
|
||
which names every candidate and why each was rejected, rather than leaving you
|
||
to infer it.
|
||
|
||
### LoRA adapters
|
||
|
||
A llama.cpp model can carry LoRA adapters, each asked for by its own name as
|
||
though it were a model:
|
||
|
||
```yaml
|
||
- name: qwen2.5-coder-7b-instruct
|
||
source: http://mirror:8081/qwen2.5-coder-7b-instruct/5d567d26/model.gguf
|
||
adapters:
|
||
- name: coder-sql
|
||
source: http://mirror:8081/coder-sql/v1/adapter.gguf
|
||
scale: 1.0 # optional; 1 when left out
|
||
profiles: [...]
|
||
```
|
||
|
||
Every replica of the model fetches every adapter and loads it at start, applied
|
||
to nothing. A request for `coder-sql` goes to the model's replicas with the
|
||
adapter applied to it alone; a request for the model gets the model. Adapter
|
||
names share the namespace of models and aliases, appear in `/v1/models`, and
|
||
follow key allowlists on their own: a key allowed `coder-sql` reaches that
|
||
adapter and not the model under it, while a key allowed the model reaches its
|
||
adapters too. Adding or removing an adapter is a change to the model; running
|
||
replicas load the new set when restarted (`llmgw ctl restart --model M`).
|
||
Besides the mirror page's model card, adapters are attached from the command
|
||
line, which reads the file first and refuses one that is not an adapter for
|
||
this model's architecture:
|
||
|
||
llmgw ctl model adapters coder --add coder-sql=http://mirror:8081/sql-lora/v1/sql.gguf@0.8
|
||
llmgw ctl model adapters coder --remove coder-sql
|
||
|
||
The adapter file must be a GGUF LoRA made for this model — llama.cpp's
|
||
`convert_lora_to_gguf.py` makes one from a PEFT adapter — and not a model with
|
||
the adapter already merged in, which is what most "LoRA GGUF" repositories hold.
|
||
|
||
### Models served outside the cluster
|
||
|
||
A model can be an OpenAI-compatible API elsewhere — a hosted provider, or
|
||
another gateway — rather than replicas here. Nothing is placed for it; the
|
||
router sends its requests on, with the provider's key and the provider's name
|
||
for the model:
|
||
|
||
```yaml
|
||
- name: cloud-mini
|
||
source: external
|
||
external:
|
||
url: https://api.openai.com # without /v1
|
||
model: gpt-4o-mini # what the provider calls it
|
||
api_key_file: /etc/llmgw/secrets/openai.key
|
||
```
|
||
|
||
Named in another model's `fallbacks`, it is used only when the cluster has no
|
||
capacity left for that model:
|
||
|
||
```yaml
|
||
- name: qwen2.5-coder-7b-instruct
|
||
fallbacks: [cloud-mini]
|
||
...
|
||
```
|
||
|
||
The key is read from its file on the control plane, never stored in the
|
||
catalog, and a key rotated in the file is used within seconds. A model whose key
|
||
file cannot be read is not offered. Everything else applies as to any model:
|
||
prices and budgets, rate limits, usage records, and key allowlists.
|
||
|
||
**Its prompts leave the cluster.** Decide who may send them: a key or project
|
||
whose allowlist leaves the external model out cannot reach it, as a fallback or
|
||
by name. The cluster page and `ctl models` mark it `external`.
|
||
|
||
### How many replicas, and how much each one carries
|
||
|
||
Replica count is changed while the cluster runs, without editing the
|
||
definition:
|
||
|
||
```
|
||
llmgw ctl scale qwen2.5-coder-3b-instruct --min 3
|
||
```
|
||
|
||
Models added at runtime only. One declared in `server.yaml` is read again on
|
||
every start, so a number set here would hold until the next restart and then
|
||
revert — change it in the file instead.
|
||
|
||
How much traffic each replica is given is the profile's `weight`, and guessing
|
||
it is how a cluster ends up with one saturated replica beside two idle ones.
|
||
Measure it instead:
|
||
|
||
```
|
||
llmgw ctl bench qwen2.5-coder-3b-instruct --save
|
||
```
|
||
|
||
Concurrency climbs until the latency target stops being met, and the last level
|
||
that held it is the capacity — peak throughput arrives long after latency has
|
||
become unusable, so throughput alone would overstate what the hardware serves.
|
||
It prints the `weight` to put in the profile, and `--save` records the figure
|
||
against the model so the dashboard can show what the cluster is measured to
|
||
carry rather than what somebody typed.
|
||
|
||
This drives real traffic and competes with real requests. Run it against a
|
||
cordoned replica, or in a window where that is acceptable.
|
||
|
||
An engine that publishes no metrics endpoint is measured from the outside only.
|
||
The run says so, and queue depth and KV occupancy are then not part of the
|
||
answer.
|
||
|
||
## 8. The first request
|
||
|
||
```
|
||
curl http://<control-plane>:8080/v1/chat/completions \
|
||
-H "Authorization: Bearer $LLMGW_API_KEY" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"model": "coder", "messages": [{"role":"user","content":"hello"}]}'
|
||
```
|
||
|
||
Or open `/chat` in a browser and use it without a key at all — the page carries
|
||
your session, and the request still goes through admission, rate limits, budget
|
||
and accounting exactly as an API call does.
|
||
|
||
## Watching it
|
||
|
||
The control plane exports both halves on one endpoint: what the cluster is
|
||
serving — requests, latency, time to first token, tokens, admission, per
|
||
endpoint — and what it is made of, per node and per GPU, including memory,
|
||
temperature, ECC faults and utilisation.
|
||
|
||
**The dashboard is the answer for most clusters.** `/admin` shows capacity per
|
||
GPU class, free whole nodes per fabric, which replicas are holding hardware
|
||
nothing is asking for, who is using the cluster and what they are running, and
|
||
the same node and model tables `ctl` prints — with no monitoring stack to stand
|
||
up beside it. One binary, no dependencies, which is the point.
|
||
|
||
Prometheus is worth adding when you want history, alerts, or one view across
|
||
several clusters. The dashboard shows what is true now; it keeps nothing.
|
||
|
||
Metrics then get their own listener, because the admin one also carries pprof
|
||
and pprof can dump the process heap, where API keys sit in plaintext:
|
||
|
||
```yaml
|
||
admin_listen: "127.0.0.1:9090" # dashboard, management API, pprof — keep local
|
||
metrics_listen: "0.0.0.0:9091" # metrics only, safe to reach
|
||
```
|
||
|
||
One scrape target for the whole fleet. The node and GPU figures come from the
|
||
control plane, which already holds them, so there is nothing to install on the
|
||
nodes and no Prometheus configuration per machine.
|
||
|
||
A starting configuration, with the alerts worth having and a provisioned
|
||
dashboard, is in [deploy/monitoring/](../deploy/monitoring/) — including where
|
||
to run it, which is not the control plane: monitoring that dies with the thing
|
||
it monitors tells you nothing at the moment you need it.
|
||
|
||
Two figures repay knowing about. `llmgw_gpu_utilization_percent` is absent
|
||
rather than zero when the driver does not report it — a flat zero across a
|
||
fleet means the measurement is missing, not that the fleet is idle. And
|
||
`llmgw_gpu_nvlink_peers` at zero everywhere means the topology is unknown,
|
||
which is what a binary built without the nvml tag reports.
|
||
|
||
## PostgreSQL, when SQLite is no longer the right shape
|
||
|
||
SQLite is the default and stays the right answer for most installations: one
|
||
file, no server, and a request path that barely touches it — usage is written
|
||
in batches, budgets are counted in memory, authentication is cached.
|
||
|
||
Two things change that. Conversations and agent notes read and write on every
|
||
turn, which is the first per-request load this store has had; and SQLite takes
|
||
one writer at a time, so those turns serialise. And a second control plane is
|
||
impossible while the store is a file on one machine's disk.
|
||
|
||
On the control plane, one command does all of it — installs PostgreSQL if the
|
||
machine has none, creates its role and database with a generated password kept
|
||
in `/etc/llmgw/secrets/store-dsn`, stops the gateway, copies the SQLite store
|
||
into it so every account and key survives, points `server.yaml` at it, and
|
||
starts the gateway again:
|
||
|
||
```
|
||
sudo llmgw server init --postgres
|
||
```
|
||
|
||
It can be given on the first run too, and then there is never a SQLite store.
|
||
The rest of this section is what that command does, for a PostgreSQL somewhere
|
||
else.
|
||
|
||
The setting is the same one, and the address decides:
|
||
|
||
```yaml
|
||
auth:
|
||
store: "@/etc/llmgw/secrets/store-dsn"
|
||
```
|
||
|
||
A path is SQLite, a `postgres://` URL is PostgreSQL, and `@` followed by a path
|
||
reads the address out of that file — which is how a DSN carrying a password
|
||
stays out of a configuration that gets copied, diffed and pasted into issues.
|
||
Keep that file at `0600` and owned by the user llmgw runs as.
|
||
|
||
Moving an existing installation is a copy, not a fresh start. Keys are stored
|
||
hashed, so starting fresh means reissuing every one of them and being unable
|
||
to say which was which:
|
||
|
||
```
|
||
sudo systemctl stop llmgw-server
|
||
llmgw keys move-store --from /var/lib/llmgw/llmgw.db --to "@/etc/llmgw/secrets/store-dsn"
|
||
# point auth.store at the new address, then
|
||
sudo systemctl start llmgw-server
|
||
```
|
||
|
||
Stop the gateway first. The copy makes no attempt to follow a store being
|
||
written to: rows landing after the table they belong to has been passed would
|
||
simply be absent, and nothing would say so. The destination must be empty, and
|
||
the source is left untouched — so the way back is to point the configuration at
|
||
the file again.
|
||
|
||
On a machine that also runs the control plane, PostgreSQL's defaults assume it
|
||
owns the box. A gateway swapped out to give the database cache is a worse
|
||
outcome than a smaller cache, so cap it: `shared_buffers = 96MB` and
|
||
`max_connections = 40` are enough for a store whose working set is measured in
|
||
megabytes.
|
||
|
||
## Conversations across models
|
||
|
||
Off everywhere until a project asks. llmgw otherwise stores no message content
|
||
at all — usage rows carry token counts, costs and latencies, never text, and
|
||
even its own chat page keeps the conversation in the browser. A gateway that
|
||
keeps nothing anyone typed is a much easier thing to have in front of a team,
|
||
so keeping it is a decision someone makes rather than a default they discover.
|
||
|
||
```
|
||
llmgw keys threads --project default --retain-hours 24
|
||
```
|
||
|
||
The retention is required. A conversation store with no expiry is a transcript
|
||
of everything a team ever typed, kept forever because nobody chose a number.
|
||
The window is measured from the last turn, so a conversation still in progress
|
||
does not expire underneath it.
|
||
|
||
A client then sends a thread id and only its new turn:
|
||
|
||
```
|
||
curl $BASE/v1/chat/completions \
|
||
-H "Authorization: Bearer $LLMGW_API_KEY" \
|
||
-H "x-llmgw-thread: my-conversation" \
|
||
-d '{"model": "<model-name>", "messages": [{"role":"user","content":"and then?"}]}'
|
||
```
|
||
|
||
A thread belongs to the project, not to the key: **any key of the project that
|
||
knows the id reads the conversation.** Use ids nobody can guess — a UUID, not
|
||
`my-conversation` — of at most 128 letters, digits and `- _ . :`.
|
||
|
||
llmgw puts the stored history in front of that turn and sends it to whichever
|
||
model is serving — **including a different one from the one the conversation
|
||
started on**. That is the point: a thread begun on a large model can continue
|
||
on a small one, and a caller dropped to a cheaper model by a spent budget keeps
|
||
their conversation.
|
||
|
||
When the history does not fit the model now serving, the oldest turns are
|
||
dropped until it does, and the reply carries `x-llmgw-thread-trimmed` with how
|
||
many. System messages are never dropped, and neither is the turn the caller
|
||
just sent.
|
||
|
||
`DELETE /v1/threads/{id}` forgets one. It is a client endpoint on purpose: the
|
||
person who said the thing should be able to unsay it without asking an
|
||
operator to read it first. A thread belonging to another project answers the
|
||
same as one that never existed.
|
||
|
||
Turning it off again stops new storage, and existing conversations go at the
|
||
next hourly prune:
|
||
|
||
```
|
||
llmgw keys threads --project default --off
|
||
```
|
||
|
||
## Memory notes for agents
|
||
|
||
Also off until a project asks, for the same reason. Where conversations are what
|
||
was said, notes are what an agent chose to keep, and they persist across
|
||
conversations:
|
||
|
||
```
|
||
llmgw keys memory --project default --retain-hours 720
|
||
```
|
||
|
||
The retention is required and is measured from the last write to a note. An agent
|
||
then saves, searches and deletes notes through `/v1/memory` or, as tools, through
|
||
the MCP server at `/mcp` (`remember`, `recall`, `forget`). Both need the API key
|
||
of the project, and notes are private to the project and to the agent id sent in
|
||
`x-llmgw-agent`. A note in another project or agent answers the same as one that
|
||
does not exist. llmgw does not add these tools to requests itself.
|
||
|
||
Turning it off stops new storage, and existing notes go at the next hourly
|
||
prune:
|
||
|
||
```
|
||
llmgw keys memory --project default --off
|
||
```
|
||
|
||
Without authentication, `threads.store` and `threads.retain_hours` in
|
||
`server.yaml` keep both conversations and notes in one shared pool. That is for
|
||
one trusted machine only; see [quickstart.md](quickstart.md).
|
||
|
||
## Two control planes
|
||
|
||
llmgw handles the hard half itself: only one places work, and agents find
|
||
whichever is leading. What it does not do is move client traffic, which
|
||
happens in front of both.
|
||
|
||
[deploy/ha/](../deploy/ha/) has the pattern and a keepalived example. The one
|
||
thing to get right is the health check: point it at `/ready`, not `/health`.
|
||
`/health` is 200 on a standby, which is alive and holds no endpoints at all,
|
||
so a load balancer using it would send half the traffic to something that
|
||
refuses every request.
|
||
|
||
## The firewall
|
||
|
||
`server init`, `node add`, `server add` and `mirror init` each keep that
|
||
machine's **internal** llmgw ports to the cluster's own networks, with an
|
||
nftables table of llmgw's own (`inet llmgw`, in `/etc/llmgw/firewall.nft`,
|
||
loaded at boot by `llmgw-firewall.service`):
|
||
|
||
| Machine | Ports, for the cluster only |
|
||
| --- | --- |
|
||
| control plane | 7443 (agents), metrics when not on loopback, 8081 (its mirror), engines and 9100 when it is a node, 2379/2380 when it holds the store's third etcd vote |
|
||
| node | engine ports (`control.port_range`, 20000–20999), 9100 |
|
||
| store member | 5432, 8008, 2379, 2380, 9100 |
|
||
| mirror | 8081, 8082, 9100 |
|
||
|
||
"The cluster's networks" are, on each machine, the network of the interface
|
||
it reaches the control planes (or the other store members) on — or just
|
||
their address, when the route goes through a gateway. Anything else that
|
||
must reach those ports, a Prometheus on another subnet or nodes behind a
|
||
router, goes in `server.yaml` before the machines are set up:
|
||
|
||
```yaml
|
||
firewall_networks: [10.0.5.0/24, 192.168.1.20]
|
||
```
|
||
|
||
Only those ports are touched. The table's policy is to accept, so whatever
|
||
else the machine serves — and whatever firewall it already has — is left as
|
||
it was; the client API (`:8080`) is not closed, being meant to be reached.
|
||
Running a command again adds to what is there rather than replacing it, so a
|
||
machine that is both a store member and a node keeps both sets. `llmgw
|
||
doctor` on a node says when the table is missing. `--no-firewall` on any of
|
||
the four commands leaves the firewall alone; `sudo systemctl disable --now
|
||
llmgw-firewall` removes it.
|
||
|
||
## Upgrading
|
||
|
||
On the control plane:
|
||
|
||
```
|
||
sudo llmgw ctl upgrade v0.10.4
|
||
```
|
||
|
||
It upgrades this machine first — fetching the release for its platform,
|
||
checking it against the release's checksums, keeping the old binary as
|
||
`/usr/local/bin/llmgw.bak-pre-<version>`, restarting `llmgw-server` and waiting
|
||
for every node to reconnect — and then each node in turn. A node's agent is told
|
||
the version over the stream it already holds, fetches and checks the release
|
||
itself, swaps it in and restarts; no ssh into the nodes is needed. Release
|
||
candidates (`v0.10.4-rc.2`) work the same way.
|
||
|
||
A node counts as done when it is back on the new version, ready, with as many
|
||
ready replicas as before. One that is not within `--timeout` (three minutes)
|
||
stops the upgrade there, so a bad release reaches one node rather than all of
|
||
them; `llmgw ctl logs <node>` says why, and the node keeps its previous binary
|
||
beside the new one. Nodes already on the version are skipped, so running the
|
||
command again resumes where it stopped. `--nodes a,b` limits it to some nodes,
|
||
and `--skip-control-plane` leaves this machine alone.
|
||
|
||
**Nothing stops serving.** Engines are started with `KillMode=process`, so they
|
||
keep running when the agent restarts, and the agent reports them back on its
|
||
next connection. The control plane holds no replica state across its own
|
||
restart either — it rebuilds the fleet from what the agents report.
|
||
|
||
**A mixed fleet is fine while you roll.** The agent protocol only ever gains
|
||
fields, and both sides treat an absent one as unknown rather than as a default.
|
||
Agents older than v0.10.4 cannot upgrade themselves: move those by hand once,
|
||
installing the release binary and restarting `llmgw-agent`, and every upgrade
|
||
after that is the command above.
|
||
|
||
**The mirror is upgraded on its own host**, since it is not an agent and its
|
||
service cannot replace its own binary:
|
||
|
||
```
|
||
sudo llmgw upgrade v0.10.5 --service llmgw-mirror
|
||
```
|
||
|
||
The same checks — the release's checksums, the old binary kept beside the new —
|
||
then a restart of the unit, waiting for it to stay up. A mirror older than
|
||
v0.10.5 does not have the command: install the release binary by hand once.
|
||
|
||
Then confirm what you have, rather than assuming:
|
||
|
||
```
|
||
llmgw ctl nodes # every agent on the new version, LAST SEEN in seconds
|
||
llmgw ctl replicas # same replicas, adopted rather than replaced
|
||
```
|
||
|
||
A replica may come back marked **`ready (stale profile)`**. That means it is
|
||
running a profile that has since been edited — the reconciler leaves a serving
|
||
replica alone on purpose, because churning one costs more than the difference.
|
||
`llmgw ctl restart <replica>` replaces it with one on the new profile, starting
|
||
the new one before stopping the old. Where the old one is all that stands in
|
||
the new one's way — a single GPU — it is stopped first, at once, and the model
|
||
is unserved while the new one loads; where the new profile fits nowhere even
|
||
then, the restart is refused and the old one keeps serving, which `ctl
|
||
replicas` shows as `restart refused:` with the reason. `--in-place` asks for
|
||
stop-then-start whatever room there is.
|
||
|
||
### Signed releases
|
||
|
||
Every install above checks a release against its published checksums, which
|
||
proves the download is what the server holds — not that the server holds what
|
||
was built. Signing closes that: CI signs the checksums, and a binary that
|
||
trusts a key installs only releases signed with it. Off until a key exists;
|
||
turning it on, once:
|
||
|
||
```
|
||
llmgw release keygen
|
||
```
|
||
|
||
1. Put the **private** key in the repository's Actions secret
|
||
`RELEASE_SIGNING_KEY`. From the next tag, releases carry
|
||
`checksums.txt.sig`.
|
||
2. Put the **public** key in `internal/release/trusted.pub` and release. Every
|
||
binary built from then on carries it and refuses releases that are not
|
||
signed with it — `ctl upgrade`, agents upgrading themselves, and
|
||
`llmgw upgrade` alike.
|
||
|
||
In that order: a build that trusts a key refuses unsigned releases, so the
|
||
secret has to be signing before the key is trusted. The first trusting release
|
||
is itself installed by a binary that trusts nothing; every one after is checked
|
||
by the one before. `LLMGW_RELEASE_KEY` overrides the built-in key, for rotating
|
||
it. The onboarding script fetches with `curl` and checks checksums only; a node
|
||
is checked from its first upgrade on.
|
||
|
||
## Backing up
|
||
|
||
`llmgw server backup` writes one archive holding everything a control plane
|
||
cannot be rebuilt without:
|
||
|
||
- **the store** — users, API keys, the tenancy tree, limits and budgets, usage
|
||
history, models added at runtime, measured capacity, and the secret every
|
||
engine key is derived from (`pg_dump` in its custom format for PostgreSQL, a
|
||
consistent `VACUUM INTO` snapshot for SQLite; both are safe against a running
|
||
gateway);
|
||
- **`/etc/llmgw`** — the configuration, the store's address, and the
|
||
certificate authority's key (`pki/`). Nothing in llmgw can reconstruct that
|
||
key; losing it means reissuing every node's identity.
|
||
|
||
Archives go to `/var/backups/llmgw-server`, readable by root only because of the CA
|
||
key, and the newest 14 are kept. Run it nightly with the timer:
|
||
|
||
```
|
||
sudo install -m 0644 deploy/systemd/llmgw-backup.service deploy/systemd/llmgw-backup.timer /etc/systemd/system/
|
||
sudo systemctl daemon-reload
|
||
sudo systemctl enable --now llmgw-backup.timer
|
||
sudo systemctl start llmgw-backup.service # one now, to see it work
|
||
ls -l /var/backups/llmgw-server
|
||
```
|
||
|
||
The control plane publishes when the newest archive was written as
|
||
`llmgw_backup_last_success_timestamp_seconds`, and **BackupTooOld** fires when
|
||
it is more than 26 hours old — including when there has never been one.
|
||
|
||
**Encrypt them.** Name who may open them in `/etc/llmgw/server.yaml`, and every
|
||
archive is encrypted to those keys with [age](https://age-encryption.org) and
|
||
named `.tar.gz.age`:
|
||
|
||
```yaml
|
||
backup_recipients:
|
||
- age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p # age-keygen's public key
|
||
- ssh-ed25519 AAAAC3Nza... ops@example # or an SSH public key
|
||
```
|
||
|
||
Keep the matching private keys **off** this machine — an archive whose key
|
||
sits beside it is not protected by it. Encrypted, a copy is safe wherever it
|
||
lands.
|
||
|
||
**Copy the archives off the machine.** They sit on the disk they back up and do
|
||
not survive losing it. Anything that copies files will do — rsync from the
|
||
mirror, a NAS mount, an offsite bucket — as long as it keeps the archives
|
||
readable by root only.
|
||
|
||
**Keys are stored hashed and cannot be recovered from a backup or from
|
||
anywhere else.** Restoring an old store restores which keys exist and who owns
|
||
them, not their secrets. A key created after the backup is gone and has to be
|
||
reissued.
|
||
|
||
### Restoring
|
||
|
||
On a machine with llmgw installed (the same version or newer) and, for
|
||
PostgreSQL, an empty database and role as `server init --postgres` would create
|
||
them:
|
||
|
||
```
|
||
sudo systemctl stop llmgw-server
|
||
mkdir /tmp/restore && sudo tar -xzf /var/backups/llmgw-server/llmgw-backup-<host>-<time>.tar.gz -C /tmp/restore
|
||
# an encrypted one: open it with the private key, from wherever it is kept
|
||
age -d -i key.txt llmgw-backup-<host>-<time>.tar.gz.age | sudo tar -xz -C /tmp/restore
|
||
cat /tmp/restore/MANIFEST # version and time it was taken
|
||
|
||
# /etc/llmgw: configuration, secrets, certificates and the CA
|
||
sudo cp -a /tmp/restore/etc/llmgw/. /etc/llmgw/
|
||
|
||
# the store — PostgreSQL
|
||
sudo -u postgres pg_restore --clean --if-exists --no-owner --role=llmgw -d llmgw /tmp/restore/store.pgdump
|
||
# the store — SQLite
|
||
sudo install -o llmgw -g llmgw -m 0600 /tmp/restore/store.sqlite /var/lib/llmgw/llmgw.db
|
||
|
||
sudo systemctl start llmgw-server
|
||
sudo rm -rf /tmp/restore
|
||
```
|
||
|
||
Running engines keep serving throughout: agents reconnect to the restored
|
||
control plane and their replicas are adopted, and because the engine-key secret
|
||
came back with the store, the router can still reach them.
|
||
|
||
**The mirror's directory** is the one thing you can rebuild, by downloading it
|
||
all again. A pinned version may no longer be upstream to download: that is
|
||
exactly what pinning it protected you from, and a copy of the directory is what
|
||
makes the protection outlive the disk.
|
||
|
||
## Reclaiming disk
|
||
|
||
`llmgw ctl nodes` has a DISK column: free space against the size it is free
|
||
out of, for the filesystems holding model weights. A dash means the node was
|
||
not asked — an agent with no `model_dirs`, or one older than this — and not
|
||
that it has no space. Shared filesystems are excluded from a node's figure,
|
||
because space on a filer belongs to everyone mounting it.
|
||
|
||
Weights llmgw downloaded for models that are no longer in the catalog can be
|
||
listed and removed:
|
||
|
||
```
|
||
llmgw ctl weights # what could be freed, per node
|
||
llmgw ctl weights --collect # delete it
|
||
```
|
||
|
||
**Only files llmgw fetched itself are ever candidates.** They are recognisable
|
||
because llmgw names them after a hash of their source, so the set still wanted
|
||
is computed from the catalog exactly rather than guessed. Anything you put on
|
||
a node — by scp, by a shared mount, by hand — is left alone however
|
||
unreferenced it looks, and no llmgw command will remove it. The nodes check
|
||
every path again before deleting it, so a control plane that is wrong cannot
|
||
talk one into losing a model that took a week to copy.
|
||
|
||
Which also means llmgw will not reclaim the storage most likely to be filling
|
||
your disk. Weights you placed yourself are yours to remove.
|
||
|
||
A profile may declare `min_disk_gb`, and a node without that much free is then
|
||
not a candidate for it — checked only on nodes that do not already hold the
|
||
weights, since those need no room for them. Without the field, disk is not
|
||
considered, which is how every profile written before it keeps behaving.
|
||
|
||
## When something does not work
|
||
|
||
**The node is `ready` but every replica fails.** Almost always a missing engine
|
||
or weights. `llmgw ctl replicas` shows the shard's error and the engine's last
|
||
output, which is the actual diagnosis.
|
||
|
||
**A model will not place.** `llmgw ctl explain-placement <model>` names each
|
||
node and the reason: wrong GPU class, not enough free cards, too little memory,
|
||
no fabric, a label it does not carry.
|
||
|
||
**Everything is slow.** On a VM, check AVX2 (step 6). On CPU, check the
|
||
quantisation — the difference between `q8_0` and `q4_k_m` is roughly a factor of
|
||
two. `llmgw ctl status` shows how full the cluster is.
|
||
|
||
**Tool calls come back as text.** The model made the call and the engine did not
|
||
parse it. `llmgw ctl tool-check --endpoint http://<node>:<port>` distinguishes
|
||
that from a model ignoring its tools, which has a different fix.
|
||
|
||
**Requests fail at exactly sixty seconds.** `timeouts.response_header` is a
|
||
liveness check for streaming and a total generation budget for non-streaming
|
||
requests. On a slow model, set `timeouts.generation` higher.
|
||
|
||
## What to read next
|
||
|
||
- `docs/choosing-models.md` — which models to run and how many, if the answer
|
||
to "we have GPUs, now what" is not already obvious
|
||
- `docs/data.md` — what is stored and what leaves the machine, for the review
|
||
that happens before this is allowed near anyone's documents
|
||
- `docs/reference.md` — every configuration key, generated from the source, so
|
||
it describes the version you are running rather than the one somebody last
|
||
wrote about
|
||
- `deploy/mirror/README.md` — the mirror in more detail, including the layout
|
||
and what belongs on which port
|
||
- `deploy/monitoring/README.md` — Prometheus, Grafana and the alerts, with what
|
||
each one assumes about this system
|
||
- `docs/fabrics.md` — InfiniBand fabrics and NVLink: what llmgw needs of them,
|
||
checking a node, and what to look at when a replica spanning nodes will not run
|
||
- `docs/node-onboarding.md` — adding nodes in more detail, and what onboarding
|
||
deliberately does not do
|
||
- `docs/onboarding.md` — giving people accounts and keys
|
||
- `llmgw-plan.md` — why the system is shaped the way it is
|
||
|
||
## Known gaps, so you find them here rather than at 2am
|
||
|
||
- **Every node fetches its own copy.** There is no seeding between nodes, so
|
||
bringing up eight nodes on a new model is eight downloads. The mirror makes
|
||
those eight local, which is most of the problem; on a fabric where the nodes
|
||
can see each other, seeding between them is the obvious next thing to build.
|
||
- **A budget without a price never binds.** Set `default_price` or `pricing`
|
||
(step 2) or every request costs zero, and a budget is something the dashboard
|
||
displays rather than something that stops anything. The Limits tab says so
|
||
where it can, which is not the same as being told before you rely on it.
|
||
- **A V100 runs vLLM 0.19.1**, not the version the rest of the cluster runs:
|
||
no build from 0.20 on has Volta kernels, and the node picks the newest that
|
||
has. llmgw's flags are checked against the current version, so run `llmgw
|
||
ctl engine-check vllm` on a V100 node once.
|
||
- **Two control planes have not been run for long.** The leader election and
|
||
agent failover work and there is a pattern in `deploy/ha/`, but every cluster
|
||
this has been run on in anger has had one. A single control plane is the
|
||
well-trodden path.
|
||
- **Multi-node replicas have not been run on real InfiniBand or NVLink.** The
|
||
scheduling, the fabric identity, the maps and the launch are written against
|
||
the real tools' formats and tested against them, not yet run on hardware that
|
||
has them. Single-node placement on real GPUs is the well-trodden path; if you
|
||
are the first to run a TP group across real cards, run `llmgw doctor --json`
|
||
on each node first and keep the reports.
|
||
- **One engine setup is weaker than the rest.** vLLM's `launch_mode: ray`
|
||
starts a Ray head without authentication, so anyone who can reach its
|
||
port can run code on the node, and Ray's ports are not among those
|
||
llmgw's firewall guards. The dashboard is off, but firewall the port to
|
||
the other nodes in the replica, or use the default `mp` launch mode, which
|
||
needs no Ray at all.
|