Files
llmgw/docs/install.md
T

61 KiB
Raw Blame History

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 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.

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 for what it sets up.

Everything between the machines is TLS, with certificates from the cluster's own authority: PostgreSQL (every remote rule is hostssl, and the control plane verifies the member it reaches), etcd (peers and clients, and a client must present a certificate), and Patroni's API, which also asks for a password before a change. A cluster set up by an earlier llmgw is moved to this by running the same command again, on the control plane that holds the authority: etcd is rebuilt on TLS with Patroni in maintenance mode, so PostgreSQL serves throughout and no failover happens.

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/ 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.

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

# /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.

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 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 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:

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:

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:

# 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:

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:

# 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:

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:

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:

- 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:

- 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:

- 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:

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/ — 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:

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.

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/ 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:

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 and named .tar.gz.age:

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.

  • 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.