install.md explains every choice and is long for someone who wants the commands. quick-install.md has only those, and what to check after each: the binary, server init, onboarding GPU nodes (with doctor first and the script fetched from the release tag, since it is not installed anywhere), a model from the mirror's explorer, keys and editors, and Caddy. Every button and path it names was checked against the pages. quickstart.md is now said to be the from-source, one-workstation path it is; README and the about page point at the new guide. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
12 KiB
Quickstart: one machine, one model, ten minutes
Written for a developer who wants to see llmgw answer a request on a single
workstation, built from source, with no systemd and no sudo. To install
llmgw on servers, read quick-install.md instead.
What you end up with: an OpenAI-compatible endpoint on localhost:8080 in front
of a real model served by llama.cpp.
Two ways to run
Steps 1 to 4 use the simple one, router-only: you start the engines yourself
and tell the router where they are with --endpoint. No certificates, no node
agent.
Step 5 uses the control plane and node agent, the same architecture as a real cluster on one machine: the agent starts and supervises the engine, and the control plane places the model.
1. Build
git clone https://gitea.mynodes.xyz/ilker/llmgw && cd llmgw
make build
make test
./bin/llmgw --version
make build writes ./bin/llmgw. Go 1.26 or later is the only requirement.
2. Check the path with a fake engine
Before involving a GPU, prove the router works. sim-engine is an
OpenAI-compatible fake that streams placeholder words.
./bin/llmgw sim-engine --listen :8001 --model llama &
./bin/llmgw server --endpoint e1=http://127.0.0.1:8001=llama &
curl -N localhost:8080/v1/chat/completions \
-H 'content-type: application/json' \
-H 'x-session-id: demo' \
-d '{"model":"llama","messages":[{"role":"user","content":"hello"}],"stream":true}'
Chunks of alpha beta gamma ... ending in data: [DONE] mean the router is
fine. Stop both before continuing (see Stopping).
3. Serve a real model
Two processes, two ports. Clients only ever talk to the router; the router
knows where the engine is because you tell it with --endpoint:
curl :8080 -> llmgw router :8080 -> llama serve :8082 -> model
(client) (the gateway) (the engine)
Requests go to 8080. Port 8082 is the engine's own, and you only touch it to check that the engine is up.
llmgw does not install engines or weights. You need:
- a
llama-serverbuild from llama.cpp (CUDA build if you want the GPU), and - a
.gguffile.
Check the card first with nvidia-smi. An 8 GB card holds a 1B model at bf16
(2.4 GB) or a 9B model at q4_k_m (5.4 GB).
Start the engine on its own port. The model name given to --alias is the name
clients will use:
llama serve \
-m /path/to/Llama-3.2-1B-Instruct-BF16.gguf \
--host 0.0.0.0 --port 8082 -ngl 99 --alias llama
-ngl 99 offloads every layer to the GPU. Confirm it is up and knows the model:
curl -s localhost:8082/v1/models
Then point the router at it. The format is id=URL=model:
./bin/llmgw server --endpoint e1=http://127.0.0.1:8082=llama
4. Ask it something
Streaming:
curl -N localhost:8080/v1/chat/completions \
-H 'content-type: application/json' \
-H 'x-session-id: demo' \
-d '{"model":"llama","messages":[{"role":"user","content":"hello"}],"stream":true}'
One reply at once:
curl -s localhost:8080/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"llama","messages":[{"role":"user","content":"hello"}],"max_tokens":100}'
Also useful: curl -s localhost:8080/v1/models and
curl -s localhost:8080/health. llmgw ctl does not work in this mode: it
talks to the control plane's admin API, which router-only mode does not have,
so ctl status answers 404.
On the machine this was written on, a 1B bf16 model on an RTX 4070 laptop GPU answered at roughly 85 tokens per second.
Several replicas of one model
Give the router more than one --endpoint with the same model name and it
spreads requests across them. x-session-id keeps a conversation on the same
replica so its cache is reused:
./bin/llmgw server \
--endpoint e1=http://127.0.0.1:8082=llama \
--endpoint e2=http://127.0.0.1:8083=llama
Stopping
pkill -f "llmgw server"
pkill -f "llmgw sim-engine"
Stop the engine the way you started it (Ctrl-C in its terminal).
What goes wrong
listening on :8080: bind: address already in use. An earlier router is
still running. ss -ltnp | grep 8080 names the process; stop it first. This is
the usual result of moving from step 2 to step 3 without stopping the fake
engine's router.
/v1/models on the engine returns an empty list. The engine was started
without -m, so it is a router with nothing to load. Restart it with a model.
error: nothing to serve. llmgw server with no --endpoint and no model
carrying profiles has nothing to do. In router-only mode, always pass at least
one --endpoint.
Requests fail with a model-not-found error. The name in the request must
match the name after the last = in --endpoint, and it must be a name the
engine actually serves (its --alias).
Answers are poor. A 1B model is a plumbing test, not a chat assistant. Swap
in a larger .gguf; nothing else changes.
5. Let llmgw run the engine (control plane and node agent)
Steps 2 and 3 start the engine by hand. Here a node agent starts and
supervises llama.cpp for you, and a control plane decides where the model runs.
Still one machine, still no sudo. Stop everything from steps 2 and 3 first:
the GPU has to be free and port 8080 unused.
curl :8080 -> llmgw server (control plane + router) <- llmgw agent (this node)
|
starts llama-server :20000
The agent dials the control plane over mutual TLS, so this needs a small private
certificate authority. Everything goes in a scratch directory, devcluster/,
which is in .gitignore.
Certificates
mkdir -p devcluster/pki
./bin/llmgw ctl cert init-ca --dir devcluster/pki
./bin/llmgw ctl cert issue-server llmgw-ctl --host 127.0.0.1 --host localhost \
--dir devcluster/pki --out devcluster
./bin/llmgw ctl cert issue-client llmgw-node01 --dir devcluster/pki --out devcluster
The name given to issue-client becomes the certificate's common name and must
equal node_id in the agent config. The control plane refuses an agent whose
claimed id differs from its certificate.
The engine wrapper
The agent starts the engine as <bin> --model ... --port .... A llama binary
that wants llama serve ... refuses every flag, so put a wrapper in front of it:
cat > devcluster/llama-server <<'EOF'
#!/bin/sh
exec /path/to/llama serve "$@"
EOF
chmod +x devcluster/llama-server
./bin/llmgw ctl engine-check llamacpp --bin devcluster/llama-server
engine-check should end with every flag llmgw builds is accepted. If your
build already ships a llama-server binary, point bin at that and skip the
wrapper.
Control plane config: devcluster/server.yaml
listen: "127.0.0.1:8080"
admin_listen: "127.0.0.1:9090"
agent_listen: "127.0.0.1:7443"
agent_tls:
ca: devcluster/pki/ca.pem
cert: devcluster/server.pem
key: devcluster/server-key.pem
models:
- name: llama
source: /path/to/Llama-3.2-1B-Instruct-BF16.gguf
routing_profile: chat
context_length: 8192
scaling: { min: 1, max: 1 }
profiles:
- name: gpu
engine: llamacpp
gpu_class: 4070-8g
weight: 1
priority: 100
startup_timeout: 5m
Three things in there are easy to get wrong:
- The model must have at least one profile. That is what switches the control
plane on. With
models: []the server starts as a plain router and exits withnothing to serve. gpu_classdecides CPU or GPU. Without it the replica is placed in the CPU pool and runs on the CPU. Get the class name from./bin/llmgw agent inventory(theCLASScolumn).- There is no
auth:block. Leave it out for a local trial; with auth on, requests need an API key.
Agent config: devcluster/agent.yaml
node_id: llmgw-node01
server: "https://127.0.0.1:7443"
mgmt_addr: "127.0.0.1"
tls:
ca: devcluster/pki/ca.pem
cert: devcluster/llmgw-node01.pem
key: devcluster/llmgw-node01-key.pem
engines:
llamacpp:
bin: /absolute/path/to/devcluster/llama-server
supervisor:
work_dir: devcluster/agent-work
Run it
Two terminals:
./bin/llmgw server --config devcluster/server.yaml
./bin/llmgw agent --config devcluster/agent.yaml
The model is not placed at once. The control plane waits about 35 seconds for the fleet to settle after it starts, then places the replica, and the agent then loads the weights. Watch it with:
./bin/llmgw ctl nodes
./bin/llmgw ctl models
./bin/llmgw ctl replicas
./bin/llmgw ctl explain-placement llama
STATUS ready in ctl models means it is serving. nvidia-smi should now show
the model in GPU memory. The same curl from step 4 works against port 8080.
ctl works here, unlike in router-only mode, because there is a control plane
for it to talk to.
Stop it with Ctrl-C in each terminal. The agent leaves the engine running when
the agent itself stops, so if you restart the agent and the GPU stays full,
pgrep -af "llama serve" will show the engine still up.
6. Memory: conversations and notes
Off unless you ask. llmgw otherwise stores nothing anyone typed. On a single machine with no API keys, one block in the server config turns on two things:
threads:
store: devcluster/data/threads.db
retain_hours: 24
Restart the server after adding it. retain_hours is required: everything kept
expires that long after it was last written.
Everything kept this way sits in one shared pool with no owner, and anyone who can reach the port and knows an id can read it. That is fine on one machine and wrong on a shared network. With auth on, threads and notes are per project instead and the block is ignored; see install.md.
Conversations (threads)
Send the same x-llmgw-thread id and only the new turn. llmgw puts the stored
history in front of it:
curl -s localhost:8080/v1/chat/completions \
-H 'content-type: application/json' -H 'x-llmgw-thread: demo' \
-d '{"model":"llama","messages":[{"role":"user","content":"My name is Deniz."}]}'
curl -s localhost:8080/v1/chat/completions \
-H 'content-type: application/json' -H 'x-llmgw-thread: demo' \
-d '{"model":"llama","messages":[{"role":"user","content":"What is my name?"}]}'
curl -X DELETE localhost:8080/v1/threads/demo # forget it
Without the block, a request carrying x-llmgw-thread is refused with
this gateway keeps no conversations rather than answered without its history.
Notes an agent keeps for itself
Notes outlive any one conversation. Each is a key, some text and optional tags,
kept per agent; name the agent with x-llmgw-agent (default default), so two
agents using the same key do not read each other's notes.
curl -X PUT localhost:8080/v1/memory/favorite-color \
-H 'content-type: application/json' \
-d '{"text":"The user likes dark green.","tags":["preference"]}'
curl 'localhost:8080/v1/memory?q=green' # search by words
curl 'localhost:8080/v1/memory?tag=preference' # or by tag
curl localhost:8080/v1/memory/favorite-color
curl -X DELETE localhost:8080/v1/memory/favorite-color
The same notes are offered to an agent as tools over MCP at
http://localhost:8080/mcp: remember, recall and forget. Add that URL to
your agent's MCP server list (HTTP transport; how you set request headers such
as x-llmgw-agent depends on the client). llmgw does not put these tools into
chat requests on its own, so an agent that is not given the server never sees
memory.
Limits: a key is up to 200 bytes, a note's text up to 16 KiB, at most 16 tags,
1000 notes per agent. Tags are lowercase letters, digits, - and _.
Where this stops
Router-only mode (steps 2 to 4) has no authentication, no placement and no model
management: you start and restart engines yourself. Step 5 adds placement, model
management and a node agent, and can add authentication as install.md
describes. For a real cluster, with several nodes, systemd and a persistent
certificate authority, follow install.md.
One caveat found while writing this page: install.md recommends starting with
an empty models: [] and adding models later with ctl model add. The control
plane only turns on when the config declares a model with profiles, so that
first start does not produce a control plane.