
Table of Contents
By Khimananda Oli | Last reviewed: September 2026
AI Linux server monitoring works best as two stages, not one. A cheap statistical detector (robust z-score over Prometheus metrics) decides that something is abnormal; a local LLM running on Ollama then reads a bounded evidence bundle and explains why, ranking causes with the read-only command that confirms each. Never ask the model to scan raw numbers — it is a correlator, not a calculator.
What is AI Linux server monitoring, and where do local LLMs actually help?
Every Linux box already emits more signal than anyone reads. Prometheus node_exporter alone publishes several hundred series per host, and a mid-sized fleet produces journal volume no on-call engineer will ever page through at 03:00. The gap is not detection — a threshold alert fires fine. The gap is the ninety seconds after the page, when you are staring at "load1 is high" and have no idea whether it is a runaway cron job, a failing disk, or a neighbour on the same hypervisor.
That gap is where AI Linux server monitoring earns its keep. A local LLM is genuinely good at correlating heterogeneous text — a metric deviation, four journal lines, a process table, and yesterday's deploy log — into a ranked hypothesis. It is genuinely bad at deciding whether 0.83 is unusual for a series whose median is 0.41. Build the system around that split and it works; ignore the split and you get an expensive, non-deterministic threshold alert.
If you have already built the classic stack described in Prometheus and Grafana: full monitoring stack, you have everything Stage 1 needs. This article adds the second stage on top rather than replacing anything.
Why not just hand the metrics to an LLM and ask what looks wrong?
It is the obvious first attempt, and it fails for four measurable reasons.
- Tokenised numbers are not numbers. A float like
0.8317is split into several tokens with no arithmetic relationship to0.4102. The model pattern-matches on digit shape, so it flags round numbers and misses genuine three-sigma drift. - Context blows up immediately. One host, 300 series, 24 hours at 15-second scrape intervals is roughly 1.7 million data points. Even downsampled to five-minute buckets that is far past a comfortable prompt, and accuracy degrades long before the hard context limit.
- Non-determinism is unacceptable in a detector. The same CPU curve must produce the same verdict every run, or your alert history becomes meaningless and you cannot tune a threshold.
- Cost scales with the wrong quantity. Scanning everything means paying for the 99.9% of samples that are perfectly normal.
A median-and-MAD calculation answers "is this abnormal" in about eight milliseconds per series, exactly, forever. Spend the model where it is irreplaceable: turning a flagged series plus surrounding text into a plausible story. The same division of labour underpins detecting metric anomalies with machine learning — the newer part is that the explanation stage now runs on your own hardware.
How do you build the statistical detector for Linux metrics?
Choose signals that fail in interesting ways
Do not detect on all 300 series. Pick the handful whose deviation actually implies an incident — the four golden signals of monitoring applied at host level:
| Signal | PromQL expression | Why it matters |
|---|---|---|
| CPU saturation | 1 - avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) | Runaway process, GC storm, crypto-miner |
| Memory pressure | 1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes | Leak, cache thrash, imminent OOM kill |
| Disk I/O wait | rate(node_disk_io_time_seconds_total[5m]) | Failing device, noisy neighbour, log flood |
| Filesystem headroom | node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} | Slow fill that thresholds catch far too late |
| Run queue | node_load1 / count by(instance)(node_cpu_seconds_total{mode="idle"}) | Normalised load, comparable across hosts |
Use median and MAD, not mean and standard deviation
Standard deviation is the wrong tool here, because the outlier you are hunting inflates the very statistic meant to catch it. One thirty-minute CPU spike drags the mean up and the deviation wide, and the next identical spike scores under two sigma. The median absolute deviation is resistant to roughly half the sample being contaminated, which is exactly the property a server baseline needs.
The modified z-score, as defined by Iglewicz and Hoaglin, is:
z_i = 0.6745 * (x_i - median(x)) / MAD(x)
where MAD(x) = median(| x_i - median(x) |) The 0.6745 constant is the 0.75 quantile of the standard normal distribution; it rescales MAD so that |z| > 3.5 means roughly the same thing as a three-and-a-half-sigma event on normal data. That threshold is the usual starting point — tighten to 3.0 if you want more candidates, loosen to 4.0 if the LLM stage is getting noise.
The detector script
This queries the Prometheus HTTP API for a 24-hour window, scores the latest sample, and prints candidates as JSON. It has no dependency beyond requests.
#!/usr/bin/env python3
# /opt/ai-monitor/detect.py
import json, statistics, sys, requests
PROM = "http://127.0.0.1:9090"
THRESHOLD = 3.5
SIGNALS = {
"cpu_saturation": '1 - avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m]))',
"mem_pressure": '1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes',
"disk_io_time": 'rate(node_disk_io_time_seconds_total[5m])',
"load_per_core": 'node_load1 / count by(instance)(node_cpu_seconds_total{mode="idle"})',
}
def series(expr):
r = requests.get(f"{PROM}/api/v1/query_range",
params={"query": expr, "start": "now-24h",
"end": "now", "step": "300s"}, timeout=30)
r.raise_for_status()
return r.json()["data"]["result"]
def modified_z(values, latest):
med = statistics.median(values)
mad = statistics.median([abs(v - med) for v in values])
if mad == 0:
return 0.0
return 0.6745 * (latest - med) / mad
candidates = []
for name, expr in SIGNALS.items():
for s in series(expr):
points = [float(v) for _, v in s["values"]]
if len(points) < 48:
continue
z = modified_z(points[:-1], points[-1])
if abs(z) > THRESHOLD:
candidates.append({
"signal": name,
"instance": s["metric"].get("instance", "?"),
"z": round(z, 2),
"latest": round(points[-1], 4),
"median": round(statistics.median(points[:-1]), 4),
})
json.dump(sorted(candidates, key=lambda c: -abs(c["z"])), sys.stdout, indent=2) Note the mad == 0 guard. A series that is perfectly flat — an idle host, a gauge stuck at zero — has a MAD of zero and would otherwise divide by zero and flag every single scrape. That one line removes most of the false positives people hit on their first day.
How do you wire a local LLM for anomaly triage?
Install Ollama and pick a model that fits your GPU
curl -fsSL https://ollama.com/install.sh | sh
sudo systemctl enable --now ollama
ollama pull qwen2.5:14b-instruct-q4_K_M
ollama list Ollama exposes both its native API on http://127.0.0.1:11434/api/chat and an OpenAI-compatible endpoint at /v1/chat/completions, so you can point any existing OpenAI client at it by changing the base URL. Rough sizing for a quantised model:
| Model size (q4) | VRAM | Triage quality | Practical use |
|---|---|---|---|
| 3B | ~2.5 GB | Weak — misses correlations | Classification only |
| 7–8B | ~5–6 GB | Usable for single-host triage | Edge boxes, RTX 3060 |
| 14B | ~9–10 GB | Good — the sweet spot | RTX 4070 Ti / A10 |
| 32B | ~20–22 GB | Marginal gain over 14B | A100 / dual 3090 |
CPU-only inference works but expect thirty seconds to several minutes per triage on a 14B model, which is fine for a five-minute detector loop and painful for anything interactive. Self-hosting an LLM: options, costs and GPU requirements covers the hardware maths in detail, and running local LLMs with Ollama for DevOps workflows covers the day-to-day operational side.
Build a bounded evidence bundle
This is the step that decides whether the whole system is useful. The model needs enough context to correlate and little enough to stay accurate. Cap the bundle at roughly 4 KB and include only read-only output:
#!/usr/bin/env bash
# /opt/ai-monitor/evidence.sh — read-only, no mutations
set -euo pipefail
echo "=== anomaly ==="
cat "$1"
echo "=== top processes by cpu ==="
ps -eo pcpu,pmem,rss,etimes,comm --sort=-pcpu | head -8
echo "=== memory ==="
free -m
echo "=== disk ==="
df -hP -x tmpfs -x devtmpfs | head -8
echo "=== failed units ==="
systemctl list-units --state=failed --no-legend --no-pager | head -10
echo "=== recent warnings ==="
journalctl --since "15 min ago" -p warning -o short-iso --no-pager \
| tail -40 | cut -c1-160 Every command there is non-mutating and bounded by head, tail or cut. The cut -c1-160 matters more than it looks: a single stack trace in the journal can otherwise consume the entire budget and push out the process table that actually holds the answer. The same bundling discipline drives AI Linux troubleshooting with an LLM, and it transfers directly to log-heavy workflows described in AI-powered log analysis.
Prompt for structured output, not prose
A triage note that a script can route is worth far more than a paragraph. Ask for JSON and constrain the schema:
#!/usr/bin/env python3
# /opt/ai-monitor/triage.py
import json, subprocess, sys, requests
SYSTEM = """You are a Linux SRE triage assistant. You receive a statistically
confirmed metric anomaly plus read-only evidence from one host. The anomaly is
real - do not re-litigate whether it is significant.
Return ONLY valid JSON:
{"summary": "<one sentence>",
"causes": [{"hypothesis": "...", "confidence": "high|medium|low",
"evidence": "which line supports this",
"verify_command": "<read-only command>"}],
"urgency": "page|ticket|ignore"}
Rules: at most 3 causes, ranked. verify_command must be read-only - never
systemctl restart, kill, rm, or any write. If the evidence does not support a
cause, say so and lower confidence rather than inventing one."""
evidence = subprocess.run(
["/opt/ai-monitor/evidence.sh", sys.argv[1]],
capture_output=True, text=True, timeout=60).stdout[:4096]
resp = requests.post("http://127.0.0.1:11434/api/chat", timeout=300, json={
"model": "qwen2.5:14b-instruct-q4_K_M",
"stream": False,
"format": "json",
"options": {"temperature": 0.1, "num_ctx": 8192},
"messages": [{"role": "system", "content": SYSTEM},
{"role": "user", "content": evidence}],
})
resp.raise_for_status()
print(json.dumps(json.loads(resp.json()["message"]["content"]), indent=2)) Three settings carry most of the weight. "format": "json" puts Ollama into constrained decoding so the reply parses. temperature: 0.1 keeps repeated triage of the same incident consistent. And telling the model the anomaly is already confirmed stops it wasting its reasoning budget second-guessing Stage 1 — a habit that otherwise eats half the output.
A typical reply on a real incident:
{
"summary": "Disk I/O wait on web-03 is 6.1 MAD above baseline, coinciding with
unrotated nginx access logs filling /var.",
"causes": [
{"hypothesis": "logrotate.service failed on 2026-08-30; access.log now 41 GB",
"confidence": "high",
"evidence": "failed units: logrotate.service; df shows /var at 97%",
"verify_command": "systemctl status logrotate.service --no-pager"},
{"hypothesis": "Backup job overlapping the traffic peak",
"confidence": "low",
"evidence": "no backup process in top-8 by CPU",
"verify_command": "systemctl list-timers --no-pager"}
],
"urgency": "ticket"
} How do you run this in production without it running you?
Run the detector on a timer, not a daemon. A systemd timer gives you logging, failure handling and systemctl list-timers visibility for free.
# /etc/systemd/system/ai-monitor.service
[Unit]
Description=AI anomaly detection and triage
After=network-online.target
[Service]
Type=oneshot
User=ai-monitor
ExecStart=/opt/ai-monitor/run.sh
TimeoutStartSec=600
PrivateTmp=true
ProtectSystem=strict
NoNewPrivileges=true
# /etc/systemd/system/ai-monitor.timer
[Unit]
Description=Run AI anomaly triage every 5 minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec=5min
AccuracySec=30s
[Install]
WantedBy=timers.target sudo systemctl daemon-reload
sudo systemctl enable --now ai-monitor.timer
systemctl list-timers ai-monitor.timer Four operational rules keep it healthy:
- Deduplicate before you notify. Hash
signal + instanceand suppress repeats for an hour, or a slow disk fill will generate twelve identical triage notes before lunch. - Attach, never replace. Post the triage note as an annotation on the existing alert. Your paging path stays whatever Prometheus Alertmanager already does; the model only adds context.
- Run the LLM unprivileged. A dedicated
ai-monitoruser with read-only access. It never holds sudo, and no code path passes model output to a shell. - Log every prompt and reply. When the model is confidently wrong — and it will be — you need the transcript to tune the bundle. Ship those to your log stack the same way you handle structured logging.
Why local, and not a cloud API?
The practical answer for most teams is both: a 14B local model handles every routine triage, and a documented escape hatch sends a sanitised bundle to a frontier model for the once-a-quarter failure nobody recognises.
What does this actually cost to run?
For a fifty-host fleet at a five-minute detector interval, Stage 1 evaluates around 200 series per run — a few seconds of Prometheus query time and negligible CPU. In steady operation, well under one percent of runs produce a candidate, so the GPU sits idle almost all the time and a single 14B model on one mid-range card comfortably serves the whole fleet.
The real cost is tuning. Budget two weeks of adjusting thresholds and trimming the evidence bundle before the output is trustworthy enough for on-call to read without rolling their eyes. Teams that skip that period end up with the thing they were trying to escape: another noisy alert channel, this one with opinions.
Guardrails you should not skip
- Never execute model output.
verify_commandis text for a human to read. The moment it reachessubprocess.run(shell=True), a poisoned log line becomes remote code execution. - Treat log content as untrusted input. Anyone who can write to your journal — including via a crafted HTTP request that lands in an access log — can attempt prompt injection. Keep the system prompt authoritative and the evidence clearly delimited.
- Keep the deterministic alert. If the LLM stage is down, the underlying Prometheus alert must still fire. AI triage is an enrichment layer, never the paging path.
- Redact before any egress. If you do add a cloud fallback, strip hostnames, internal IPs and tokens first.
Where to go next
Start narrow. Put AI Linux server monitoring on one noisy host, one signal, and a 7B model, and read every triage note for a fortnight. You will learn more about your evidence bundle in those two weeks than from any amount of model shopping — and once the bundle is right, swapping in a larger local LLM is a one-line change. Then extend the same pattern to containers and pods, where the correlation problem is harder and the payoff larger, as covered in AIOps explained: using AI to run modern infrastructure.
If you would rather have AI Linux server monitoring built and tuned against your own fleet than assembled from scratch, my DevOps and cloud consulting services cover exactly this — designing monitoring and AIOps pipelines for teams running Linux in production.