Add a Health Check and Watchdog to a Local LLM Server
A local inference server rarely dies cleanly. The process stays up, the port stays open, and /v1/models keeps returning JSON while the engine is stuck behind a full KV cache or quietly failed to load its weights. A watchdog built on Restart=always will babysit that broken state for days. You need two separate pieces: a probe that answers “is the model actually replying”, and a restart path that does not kill the requests you already accepted.
Decide what healthy means before writing the check
Each layer of checking costs more to run and tells you something different. Pick the layer that matches the failure you actually care about, then stack two of them.
| Check | Cost per run | Catches | Misses |
|---|---|---|---|
| Process alive / TCP connect | near zero | Crash, closed port, dead unit | Deadlocks, engine not loaded |
GET /health or GET /v1/models | near zero | Frontend down, engine still starting | Wedged scheduler, wrong weights |
| One short completion, total latency | one prefill plus a few tokens | Hard hangs, saturated queue | Slow first token hidden inside fast decode |
| Time to first token (streamed) | same request as above | KV cache pressure, prefill backlog | Slow decode, wrong answers |
| Known-answer probe at temperature 0 | same request as above | Wrong model, broken tokenizer, corrupt weights | Throughput and latency regressions |
| Long-context probe (run rarely) | one large prefill | Memory fragmentation, context limit mismatch | Anything a short request would show |
Two details matter here. First, vLLM’s /health endpoint returns success once the engine is ready and fails while it is still loading, which makes it a good startup gate and a poor liveness gate: it keeps returning success after the engine wedges. Second, fold first-token timing and the known-answer check into the same request. A single short generation gives you both, so the probe costs one prefill and a handful of decoded tokens.
On determinism: greedy decoding at temperature 0 is close to deterministic, but batching and kernel choices mean the exact string can shift. Assert that the reply contains a fixed token rather than equals it, and put a random suffix in the prompt so a prompt cache cannot answer on the model’s behalf.
Write the probe so its exit code classifies the failure
Most health checks fail badly: they return one generic error, and the watchdog treats every failure as “restart”. Distinguish the failures a restart fixes from the ones it only hides.
#!/usr/bin/env python3
# /opt/llm/healthcheck.py
import json, os, time, urllib.request, uuid
BASE = os.environ.get("LLM_BASE", "http://127.0.0.1:8000")
MODEL = os.environ["LLM_MODEL"]
BUDGET_S = float(os.environ.get("HEALTH_BUDGET_S", "20"))
MARKER = "READY-" + uuid.uuid4().hex[:6]
def chat(payload):
req = urllib.request.Request(
BASE + "/v1/chat/completions",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
return urllib.request.urlopen(req, timeout=BUDGET_S)
def probe():
payload = {
"model": MODEL,
"messages": [{"role": "user",
"content": f"Reply with exactly this token: {MARKER}"}],
"temperature": 0,
"max_tokens": 16,
"stream": True,
}
start, ttft, chunks = time.monotonic(), None, []
with chat(payload) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data:"):
continue
body = line[5:].strip()
if body == "[DONE]":
break
delta = json.loads(body)["choices"][0]["delta"].get("content")
if delta:
if ttft is None:
ttft = time.monotonic() - start
chunks.append(delta)
return ttft, time.monotonic() - start, "".join(chunks)
if __name__ == "__main__":
try:
ttft, total, answer = probe()
except Exception as exc:
print(f"unreachable: {exc}")
raise SystemExit(2) # process or port is gone
if MARKER not in answer:
print(f"wrong answer: {answer!r}")
raise SystemExit(3) # engine answered, content is wrong
print(f"ok ttft={ttft:.2f}s total={total:.2f}s")
if ttft > BUDGET_S / 2:
raise SystemExit(4) # correct but slow: alert, do not restart
raise SystemExit(0)
Now the exit code carries the decision. 2 means the server lost its ability to answer, which a restart usually fixes. 3 means it answered something wrong, which a restart will reproduce. 4 means the machine is working but under pressure. Your watchdog and your alerting can share one script.
Drain first, restart second
systemd should run the timer and hold the restart policy:
# /etc/systemd/system/llm-healthcheck.service
[Unit]
Description=LLM generation probe
After=llm-server.service
[Service]
Type=oneshot
User=llm
Environment=LLM_MODEL=your-model-name
Environment=LLM_BASE=http://127.0.0.1:8000
Environment=HEALTH_BUDGET_S=20
ExecStart=/opt/llm/healthcheck.py
SuccessExitStatus=0 4
# /etc/systemd/system/llm-healthcheck.timer
[Unit]
Description=Run the LLM probe every minute
[Timer]
OnBootSec=5min
OnUnitActiveSec=60s
AccuracySec=5s
[Install]
WantedBy=timers.target
SuccessExitStatus=0 4 keeps the slow-but-correct case out of your failure logs. Exit 3 stays a failure but triggers nothing automatic, which is intentional. A wrong answer is a page-a-human event, not a restart event.
For the restart itself you have a choice about in-flight work:
| Strategy | In-flight requests | Downtime | When it fits |
|---|---|---|---|
Immediate systemctl restart | Dropped | Short, but every retry costs latency | Single user, batch jobs, clients that retry |
| Drain at the proxy, then restart | Completed | Seconds of refused connections | Anything with interactive users |
| Two instances behind a proxy, rotate | Completed | None visible | Enough VRAM for two copies of the model |
| Reload weights in place | Depends on the server | Long | Only if your server exposes it cleanly |
A drain wrapper is about twenty lines:
#!/usr/bin/env bash
# /opt/llm/drain-restart.sh, run only after healthcheck.py exits 2
set -euo pipefail
MAX_WAIT=${MAX_WAIT:-120}
# 1. Stop routing new work to this instance.
printf 'backup' > /etc/caddy/active-upstream
caddy reload --config /etc/caddy/Caddyfile
# 2. Wait until the running-request gauge reaches zero.
for _ in $(seq 1 "$MAX_WAIT"); do
active=$(curl -fsS http://127.0.0.1:8000/metrics \
| awk '/^vllm:num_requests_running/ {print $2; exit}')
[ "${active:-0}" = "0" ] && break
sleep 1
done
# 3. Restart, wait for readiness, restore traffic.
systemctl restart llm-server.service
until curl -fsS http://127.0.0.1:8000/health >/dev/null; do sleep 2; done
printf 'primary' > /etc/caddy/active-upstream
caddy reload --config /etc/caddy/Caddyfile
The gauge name depends on your build; if you do not scrape metrics, ask the proxy for its active connection count instead. The order is the point: flip the edge, wait for quiet, restart, wait for /health, flip back. If you cannot drain, at least put a proxy in front that fails fast with a 503 and make clients retry with a short backoff. A retry on a refused connection is cheaper than a truncated stream.
Keep StartLimitBurst on the server unit so systemd gives up after a few attempts. A server restarting every three seconds is harder to debug than one that stays down and pages you. The limits themselves are covered in tuning a watchdog so it does not flap.
When a restart is the wrong fix
- The model never becomes ready after a config change. Check
journalctl -u llm-serveranddmesgfor out-of-memory at load time. Restarting repeats the same load. Lower--gpu-memory-utilizationor--max-model-lenfirst. A previous process still holding VRAM can also make the next start look broken. - Every restart produces a wrong-answer probe. Inspect the model path and checksum on disk before you touch the service. You are reloading bad weights on a schedule.
- Latency climbs and stays there. A restart clears the KV cache and buys minutes. Check
nvidia-smifor clocks and temperatures, check queue depth, and look for the client sending enormous prompts. - The probe fails right after you edited launch flags. Roll the config back. Restarting with the same flags is a coin flip.
- Only the long-context probe fails. Your configured context limit and your advertised limit disagree. Restarts do not reconcile them.
The rule to keep: restart when the process lost its ability to answer, not when its answer is wrong or slow. For how these machines degrade over longer stretches, see one week of watching a home DGX LLM server.
What to do next
- Add
/opt/llm/healthcheck.pyand run it by hand until you trust its four exit codes. - Wrap it in a systemd timer at 60 seconds with
SuccessExitStatus=0 4, and setStartLimitBurston the server unit so a crash loop stops itself. - Point alerting at exit 3 and exit 4 rather than the unit’s state. Those are the failures systemd cannot see.
- Write the drain step before enabling the restart step. A watchdog that drops streams gets disabled by its owner within a week.
- If you want the systemd units, memory planning and probe already assembled for a GB10 box, the DGX Spark LLM Deployment Kit is a $79 one-time download at https://slashmaster6.gumroad.com/l/bppdqp.
Get DGX Spark LLM Deployment Kit
DGX Spark LLM Deployment Kit โ $79, one-time payment, instant download. See the full breakdown on the review page.
Related reading
- The Ultimate Guide to AI Automation 2026
- How I Built a 10-Product Digital Business
- Building an AI Content Factory
๐ Want to try before you buy?
Both samples are 100% free. Enter your email to unlock the download links and get our 3-day AI toolkit drip in your inbox.
No spam. Unsubscribe anytime. We respect your privacy.
Or browse the full catalogue in the Slashman Tools storefront.
Published by slashman413 โ writing practical, evergreen guides on money, productivity, developer tooling and the web. More about this site โ
Frequently Asked Questions
Why not just set Restart=always in the systemd unit?
That only reacts to the process exiting. A wedged engine keeps the process alive and the port open, so systemd never fires. You need a separate probe that actually sends a request.
Should the health check run a full generation each time?
No. Keep it to a short prompt with a small max_tokens so it does not add queue pressure. Run a longer context probe rarely, as its own check.
What should the watchdog do when the server is slow but still correct?
Alert and investigate rather than restart. A restart clears the KV cache and hides the real cause: queue depth, thermal throttling or oversized client prompts.
๐ Recommended Tools
๐ Related Articles
- What Is Context Engineering? The New Skill After Prompt EngineeringContext engineering explained: giving AI models the right context at the right time. Techniques, examples, and โฆ
- Prompt Engineering Guide for Beginners (2026): Frameworks, Examples, and MistakesThe complete prompt engineering guide for beginners: frameworks, real examples, common mistakes, and โฆ
- AI Agents for Beginners: What They Are, How They Work, and How to Build Your First OneAI agents explained for beginners: definitions, how they differ from chatbots, real use cases, and a โฆ
- Local LLM Deployment Guide: Running AI Models on DGX Spark in 2026A complete guide to local LLM deployment in 2026. Learn how to securely run open-source AI models using the โฆ
๐ฌ Free Weekly AI Product Guides
New tools, templates and automation walkthroughs โ plus hands-on updates on the Slashman Tools catalogue. One email a week, zero fluff.
Free forever ยท No spam ยท Unsubscribe anytime ยท Sent instantly