Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

ch25 · Observability for Serving Engines

The problem

Host metrics tell you the machine is busy. They cannot tell you why a request was slow, because every reason lives inside the engine: it waited behind a prefill (ch10 · Chunked Prefill and Scheduling Policy), it was preempted (ch08 · Paged Attention and the Block Manager), it missed the prefix cache (ch09 · Prefix Caching), its batch was starved (ch07 · Continuous Batching). An engine that exports CPU, memory and request rate is an engine you cannot debug.

Worse, the obvious signal is actively misleading. ch03 · The Arithmetic of Inference explains why: decode is memory-bandwidth-bound, so a replica can be completely saturated — unable to take another sequence without hurting everyone on it — while its compute utilisation looks unremarkable. Utilisation does not track the constraint, so it cannot predict the failure.

The signals worth exporting

One criterion for inclusion: does this change what you would do next?

metrics.py
class EngineSnapshot:
    """One step's worth of engine state — the sample a scrape would take."""

The first five explain the sixth. That is the whole design.

Sample per step, not per scrape

metrics.py
class Metrics:
    """Everything worth exporting from one engine, and nothing that is not.

    Sampling per step rather than per scrape is deliberate. A scrape every fifteen seconds cannot
    see a queue that formed and drained in two, and those are exactly the events that produce the
    tail latency someone is complaining about.
    """

A scrape every fifteen seconds cannot see a queue that formed and drained in two — and those are precisely the events that produce the tail latency someone is complaining about. The engine has a natural sampling point, its own step, and using it costs:

QuantityValue
Wall time, instrumented6.414s
Wall time, bare6.086s
Overhead+5.4%
Steps sampled288
Peak queue depth34
Peak KV utilisation24%

A few percent, for a complete per-step record. That is a good trade on any engine you intend to operate, and the alternative — sampling rarely enough to be free — is how a serving incident becomes unexplainable after the fact.

Histograms are bucketed rather than exact for the same reason a running server differs from a benchmark: bounded memory matters more than a precise percentile, and the honest way to present that is to report the bucket edge rather than pretend to precision the data does not have.

metrics.py
class Histogram:
    """Fixed-bucket histogram, which is what a serving metric almost always wants.

    Keeping every observation is what a benchmark does; a running server keeps counts per bucket,
    because the memory is bounded and the percentiles are still good enough to alert on. The cost is
    that a percentile is only as precise as the bucket it lands in, and the honest way to present
    that is to report the bucket edge rather than pretend to a precision the data does not have.
    """

The measurement: which signal moves first

Drive the engine into overload and watch both series on the same axis:

From stepMean queue depthPeak queue depthKV utilisationTTFT p95
12.3294%0.0363s
2615.0257%0.035s
5130.443410%0.7724s
7630.323212%1.3581s
10124.762814%1.6753s
12621.042216%1.98s
15115.521818%2.6351s
17610.21321%2.8079s
2015.36922%3.2745s
2261.04324%3.7265s
2510.0023%3.9202s
2760.0023%4.3801s

Conditions: TinyGPT (reference, random weights) (5,838,080 params), 4x x86_64 CPU, torch 2.14.0+cu130, 48 requests at 24.0/s, deliberately beyond capacity, arrival rate 24.0/s, measured 2026-09-13T19:12:07+00:00.

Three things, and each one is an operational rule.

Queue depth leads. It is already deep in the early windows while TTFT p95 is still healthy. An alert on queue depth fires while there is still time to do something; an alert on latency fires after users have been affected.

Queue depth recovers first, and latency keeps rising after it. By the later windows the queue is empty and TTFT p95 is still climbing — because the requests finishing then are the ones that queued during the peak, and their clock started when they arrived. Latency is a report about the past. Anyone reading a latency graph during an incident is reading history, and the instinct to keep escalating after the cause has cleared comes directly from this lag.

KV utilisation tells a different story from both. It rises steadily and never approaches its limit: on this trace memory was never the constraint. That is a useful negative — it rules out an entire class of fix, and without the signal you would be guessing.

What to alert on

Tracing

A metric says the fleet is slow; a trace says where one request’s time went. The spans worth having map exactly onto this book’s chapters: queued, prefill (with a cache-hit attribute), each decode step’s batch, and preemptions. Two attributes make a trace far more useful than the default: tenant (ch19 · Multi-Tenancy and LoRA at Serving Time) and chain or session id (ch22 · Agents and Tool Use), because without the second the agent latency problem is invisible at any sampling rate.

The cost

Key takeaways

Looking ahead

ch25 · Observability for Serving Engines’s signals say when the engine is in trouble. ch26 · Reliability and Operations decides what it should do about it — which turns out to be refusing work, on purpose, and doubling goodput by doing so.

Further reading

The SRE literature on burn-rate alerting and on the four golden signals transfers to serving almost unchanged, and is better written than anything specific to LLMs. For the engine-specific signals, read what vLLM and SGLang actually export: the overlap between their metric names and the list above is high, and the places they differ are usually places where one of them learned something.