Systems · AI & Agents

Zeus

My Linux development workstation, which also serves local LLMs to my coding agents, runs image generation and acts as a CI runner. One GPU and one pool of RAM are shared across all of it, with cgroup limits and Grafana telemetry showing what limits each workload.

Active · 2026

vLLM · CUDA · Nix · Grafana

Zeus

Zeus is the machine I write software on. It is also where my coding get their model, where images get generated, and where a builds and tests one of my repositories. It runs Ubuntu 24.04 with a Wayland session for day-to-day development, and local are served on the same box behind an . The default backend is now Strata, running Swift 1.5 Flash Next: a 125B-class mixture-of-experts model that keeps most of its expert weights in the machine’s 192 GB of RAM and a hot subset on the GPU. When I need images rather than text, takes over the GPU.

I built it to answer questions I couldn’t answer from a hosted API. How practical is good local inference on consumer hardware? Can one machine be a pleasant workstation and a model server at the same time? When throughput disappoints, what is actually the limit: compute, memory bandwidth, the , batching, the CPU or scheduling? And can a local model serve an interactive coding agent well enough that I stop reaching for the cloud?

Those turned out to be the easy half. Once Zeus was doing all of it, the question changed from whether the hardware can run these workloads to how they should share it safely and predictably. Most of what I’ve built since is an answer to that: a deliberate switch for the GPU, resource limits for , protection for the terminals my agents run in, and enough telemetry to see what is going on.

Why run models locally

Hosted models are convenient, and for plenty of work they are the right choice. Running locally buys something different: prompts and code never leave the machine, I choose the model, the and the serving flags, and I can see and change every layer between a request and the GPU. The marginal cost of another request is electricity, which makes it cheap to run experiments I wouldn’t pay per token for. None of that makes local inference cheaper in general. A GPU sitting idle costs money too.

Architecture

The machine is an AMD Ryzen 9 9950X with 192 GB of DDR5 in four 48 GB DIMMs, 4 TB of NVMe storage and one NVIDIA RTX 3090 with 24 GB of . I sized the power supply and cooling for sustained inference rather than bursty desktop use, and left room for a second GPU.

Architecture diagram of Zeus. Inside one machine, interactive development and a GitHub Actions runner both draw on a shared pool of CPU, RAM and disk IO, governed by cgroup limits and oomd. Separately, clients including coding agents and Open WebUI call an OpenAI-compatible API on port 8090. Four backends can answer it: Strata by default, running the Flash Next 125B mixture-of-experts model with its experts in host RAM; syv-vLLM serving Swift-Qwen 27B; llama.cpp for fallback and embeddings; and ComfyUI for image generation. GPU arbitration uses systemd conflicts so only one GPU-heavy workload holds the RTX 3090 at a time. Collectors on Zeus, Grafana Alloy, a DCGM exporter and node_exporter, send telemetry to a Grafana stack on a separate host, where Prometheus, Loki and Tempo feed Grafana. Grafana alert rules post to Zulip.
One machine, two kinds of sharing: arbitration for the GPU, and resource policy for CPU, memory and disk. Everything shown exists today.

There are four parts. Clients speak the OpenAI API, so any tool that can point at a base URL can use a local model without knowing it is local. Several backends can serve that API, and a small control layer decides which GPU-heavy workload runs at any moment. Everything else, from the editor to CI, competes for the same CPU, memory and disk, so it gets resource policy rather than a switch. Telemetry leaves the machine for a separate stack, and alerts from it reach me in .

Serving local models

used to be the whole answer here. It still serves the Qwen-family models, including the 27B fast build from [swift-qwen3.8-rtx3090](/projects/swift-qwen3-8-rtx3090), and it stays installed as an alternative. The default backend since October 2026 is Strata, serving Swift 1.5 Flash Next with its experts in host RAM, and [ai.liamwh.com](https://ai.liamwh.com) fronts it through Open WebUI. The host is on my tailnet, so the link only resolves for me. The OpenAI-compatible endpoint is what makes any of this practical: coding agents, editor integrations and scripts all talk to Zeus the same way they would talk to a hosted provider, and swapping backends is a server-side change.

Coding agents are a demanding workload for a single consumer GPU. They send long prompts full of files and tool output, they reuse most of that prompt on every turn, and they need a large for long sessions. That shapes most of the serving choices: how much VRAM goes to weights and how much to the KV cache, whether pays off, and which quantisation keeps quality while leaving room for context. swift-qwen3.8-rtx3090 is one piece of work that came out of this: rebuilding the parts of a 27B model so it runs faster on this GPU.

Adding Strata

After months of work on dense 27B models I wanted to know what a much larger model would do to my daily work. Swift 1.5 Flash Next is a 125B-class mixture-of-experts model. It is far too large to sit in 24 GB of VRAM as weights, but only a fraction of its experts are active per token, so an engine that keeps the expert weights in host RAM and caches the hot ones on the GPU can still serve it quickly. That became realistic when I took Zeus from 96 GB to 192 GB of DDR5.

Making it the default changed more than a model swap usually would. The engine needed a Nix-managed serving layer, a unit in the GPU arbitration group and the same authenticated port 8090 the other backends use. Open WebUI had to learn which backend owns that port. Home Manager’s service activation had to stop quietly restoring the previous boot default. The full account, including the CUDA and packaging failures and every measurement, is in strata-swift-flash-next-rtx3090.

What it buys me is headroom. A 262,144-token context, decode around 100 tokens per second on coding prompts, and one backend that agents, the web UI and scripts share.

Sharing the GPU

LLM serving and image generation both want most of the 24 GB. vLLM reserves a fixed share of VRAM up front for weights and , Strata fills a large expert cache on the card while pinning the rest of the weights in RAM, and an image pipeline has its own large working set. Running two at once would mean shrinking one of them until neither is useful.

So I don’t try. Five GPU-heavy workloads are declared on Zeus, Strata, Swift-Qwen, syv-vLLM, llama.cpp and ComfyUI, and their systemd units carry mutual conflicts: starting one stops the others, so exactly one holds the card. Switching is an explicit operation rather than something that happens when two processes collide and one of them fails to allocate.

Backend selection, service lifecycle and credentials belong to the machine’s declarative configuration. The units, launch profiles and switching wrappers come from Home Manager, and the backends draw their API key from the same SOPS-managed local LLM secret, with llama.cpp keeping its own. Open WebUI orders itself after the inference units without requiring them, so restarting the web UI can never drag a backend onto the GPU I had deliberately given to something else.

That bluntness is affordable for one resource. CPU, memory and disk can’t be handed over one workload at a time, because the workstation is in use throughout.

A workstation first

Zeus is still my primary development machine. My user environment is managed with on top of Ubuntu, so shells, editors, CLI tools and their configuration are declared rather than accumulated, and I can rebuild the environment instead of repairing it. Everything else has to live alongside that without taking it over. A model server or a CI job that makes the editor stutter would defeat the point of the project.

CI and resource isolation

Zeus is also a self-hosted GitHub Actions runner for one private repository: Cargo builds and tests, tests that start containers, and image builds, with a warm . A fast local machine suits all of that, and all of it can starve an editor. The runner is an unprivileged account with no access to my home directory, and pull-request and fork workflows never select it.

Its unit encodes the priority. CPU and IO weights of 25 and a nice value of 10 make interactive work win under contention, and because they are weights rather than quotas, CI still gets the whole machine when it is idle. Memory has two ceilings: at 26 GB the kernel starts reclaiming from the runner, and at 34 GB a runaway build is killed inside its instead of taking the desktop with it.

Ceilings on CI weren’t enough, because they cover only CI. On 1 September the whole machine locked up. Both devices had been full for over 13 hours and load was above 400, yet nothing was killed. The kernel’s out-of-memory killer doesn’t fire while compressed-swap reclaim keeps slowly succeeding, and was running with no cgroup it was allowed to act on. The runner’s ceiling had held. The freeze was the sum of everything else.

I told oomd to kill the cgroup using the most swap once swap passed 90 per cent. The next day it did exactly that, and killed Herdr, the background server that hosts the terminals my coding agents run in. Herdr itself was using about 156 MB. But my launcher had started it in a single cgroup together with the terminal window and two browsers, so oomd saw a 61 GB victim and took the whole session, agents included. The policy was right and the cgroup layout was wrong.

Fixing the layout took more than one round. Each pane now starts in its own systemd scope, so a kill takes out one pane’s process tree rather than the session. Three more kills over the following days showed that moving a process to another cgroup doesn’t move the memory already charged to it, so Herdr’s server was still blamed for swap its panes had used. Panes therefore enter their scope at birth, and Herdr’s service is barred from swap, restarts without limit and has a watchdog. CI overlapped with some of those kills but wasn’t needed to cause them: the terminals alone held tens of gigabytes.

Under real memory exhaustion, oomd should now kill one leaf cgroup, such as a pane, a browser or a CI job, and leave the rest alone. I’ve verified the containment on live panes: stopping one pane’s scope killed only that pane, and a memory-capped scope was killed in isolation. I haven’t yet run a full exhaustion test on a machine I use every day.

None of this is about capacity. Zeus has enough hardware for each of these workloads. The work is deciding who yields.

Observability and alerts

nvidia-smi tells me the GPU is busy. It doesn’t tell me whether that busyness is useful. A GPU can report near-full utilisation while requests queue, the KV cache is full and each new waits on memory bandwidth rather than compute. Utilisation says a kernel was running, not how much work it did.

To see the difference I collect telemetry from the inference backends, the GPU and the host. Zeus runs only the collectors: the serving backend’s /metrics endpoint, where Strata exports the same metric names vLLM uses, NVIDIA’s and for metrics, and for vLLM’s traces and logs. , , and Grafana run on a separate always-on host in my home network, as a single grafana/otel-lgtm container that also monitors everything else I self-host. A slow request can be followed from its trace into the logs around it, and the history is still there when Zeus is the machine in trouble. Grafana is the one place I look at all of it.

The dashboards are organised around relationships rather than single numbers:

  • Queue depth and concurrency against KV-cache utilisation. When the cache fills, vLLM can’t admit more requests, and queueing grows even while the GPU looks busy.
  • Prompt throughput against output throughput. and stress the GPU differently. Agent workloads are prefill-heavy, and prefix-cache hits change that balance.
  • Time to first token against end-to-end latency. tracks queueing and prefill. Time per output token (inter-token latency) tracks decode. Which one dominates end-to-end latency says where to look.
  • GPU utilisation, VRAM, temperature and power against all of the above. These show whether the hardware is the constraint or just the thing that is visibly busy.
  • CPU, memory and host health. Zeus is a workstation and a CI runner as well, so I need to know when inference is slowing down everything else, and the reverse.
Screenshot of the top of the Zeus vLLM Grafana dashboard. Twelve stat panels: vLLM up, GPU utilisation 19 per cent, 22.4 of 24.1 GB VRAM used, KV cache 0 per cent, time-to-first-token p95 2.1 seconds, end-to-end latency p95 4.4 seconds, request rate 0.09 per second, prompt throughput 6 tokens per second, output throughput 9 tokens per second, zero running and waiting requests, prefix cache hit rate 0.0 per cent. Below, three charts show the same short burst of concurrent requests: request rate peaking near 0.17 per second, output token throughput peaking near 18 tokens per second, and running requests briefly reaching about seven.
Overview and traffic rows after a short burst of concurrent requests: rate, token throughput and concurrency all rising and falling together.
Screenshot of the dashboard's latency section with three charts over the same burst: time to first token at p50 450 milliseconds, p95 2.12 seconds, p99 2.42 seconds; end-to-end request latency at p50 2.29 seconds, p95 4.38 seconds, p99 4.88 seconds; and time per output token at p50 17.5 milliseconds, p95 24.3 milliseconds, with inter-token latency p95 at 72.2 milliseconds.
The same burst split by latency type: time to first token against end-to-end latency, and time per output token an order of magnitude below both.

A dashboard only helps while I’m looking at it, so Grafana alert rules post to Zulip as well. They run on the same telemetry, and the firing and resolved messages for an alert share one topic, so an incident reads as a single thread. The same alerts also go to Telegram, so an outage of Zulip itself can’t stay silent. That moves the stack from something I inspect to something that tells me when to look.

Host rules for a machine going down and for memory pressure cover Zeus like any other host. Seven more are specific to inference: the backend being down, GPU over-temperature, VRAM headroom, KV-cache pressure, a request backlog, time to first token and preemptions. The thresholds are plain Prometheus rules, Grafana does the routing, and each alert gets its own Zulip topic. The backend-down rule has to know about GPU arbitration. An inference backend being off while ComfyUI or another backend holds the card is normal, so it fires only when nothing is holding the card.

These dashboards are the measurement layer for the experiments I publish from Zeus.

Current baseline

What the two backends do today, one command apart:

measurevalue
Strata decode, coding prompts88 to 100 tokens per second
Strata time to first token, 247k prompt107.7 s, marker retrieved
Strata configured context262,144 tokens
Swift-Qwen decode, MTP long context98.4 tokens per second
Swift-Qwen VRAM under load22,225 MiB of 24 GB
Swift-Qwen TTFT, short burstp50 450 ms, p95 2.12 s
Swift-Qwen time per output token, burstp50 17.5 ms, p95 24.3 ms

The Strata rows come from the strata-swift-flash-next-rtx3090 benchmarks. The Swift-Qwen rows come from swift-qwen3.8-rtx3090, except the latency rows, which are read off the dashboard screenshots above; the burst was a handful of concurrent requests, not a load test, so treat those as a sanity check rather than a capacity figure. I have not run the two backends against each other on matched tasks, and the Strata write-up explains what such a comparison would need. I haven’t yet measured a controlled concurrency sweep on either backend.

What’s next

None of this is done yet. There are three directions.

More compute. The next experiment is one GPU against two. I plan to add another high-VRAM NVIDIA card, and the platform was built with that in mind. The comparison is serving of one model against independent workers each serving their own, measured on throughput, TTFT, long-context behaviour, concurrency, power draw and real coding-agent sessions. Around that I want to compare model sizes and quantisation strategies on the same workloads, and find where local models hold up in real agent sessions and where they don’t.

Better isolation and scheduling. Switching between LLM serving and image generation is still manual, and I’d like something that decides for me. The resource policy is reactive too. The limits come from peaks I’ve observed, CI containers have no memory ceiling of their own, and I still owe the machine a proper memory-exhaustion test when I can take it out of daily use.

Closing the operational loop. This is the piece that was still a plan when I first wrote about Zeus, and it now exists. Hermes, an open-source agent runtime, runs on Zeus as an unprivileged user that listens in Zulip. A firing alert from the trusted Grafana integration wakes it. It starts a session, queries Prometheus, Loki and Tempo through read-only tools, reads the infrastructure repository in a rootless sandbox, and if a repository change is the answer, opens a draft pull request. Diagnosis and PR go back into the same Zulip topic, and I approve or reject. Investigations run on the local inference backend with no hosted fallback: if the local model is down, the loop posts the alert context and a telemetry summary and marks it as needing a human.

Vertical flow diagram of the incident loop on Zeus, all of it built. A Grafana alert rule fires and the alert is posted to a Zulip topic. Hermes starts an agent session, woken only by firing alerts from the trusted integration. The local model on Zeus's active inference backend reads the alert, telemetry, logs and code, and produces a diagnosis and a draft pull request when a change is the answer. Both are posted back to the Zulip topic. The last step, in green, is human review in which I approve or reject; nothing merges or deploys without it.
The incident loop as built. Detection, investigation and the draft PR are automated; the last step is mine. If the local model is down, the loop degrades to posting alert context and a telemetry summary for a human.

Whether the local model is good enough at diagnosing its own host is still the open question, which is why remediation stops at a draft PR for review. Everything up to that point runs on its own.

I’ll publish the results as write-ups with the measurements behind them.

Open chat

Interested in working together? Reach out.

Strategy, architecture, and implementation — from workflow to production.

© 2026 Liam Woodleigh. All rights reserved.