I Taught My LLM Proxy to Watch Itself
Two posts ago I put an OpenAI-shaped proxy in front of Claude, and one post ago I taught it to wait on purpose so concurrent requests get grouped into admission windows instead of stampeding the upstream. That microbatching post ended with a promise: the next brick is a cost and latency observatory, so the tail latency I keep talking about stops being a claim and starts being a graph. This post builds that brick.
The honest problem up front: a proxy that translates and batches is still a black box until it can answer two questions about itself, how fast is it and what is it costing me. Both of the things it already does are claims about behavior under load, and until now there was no way to check either one.
I also built a small interactive dashboard of the metrics surface, which makes the shape click faster than the JSON does: observability.html on GitHub Pages (open it in a browser to see the percentiles and the cost breakdown laid out).
You cannot tune what you cannot see
The proxy translates between the OpenAI and Anthropic shapes, and it can group concurrent requests into a short window before fanning them out. Did windowing actually smooth the upstream call rate? Did it add latency, and if so how much, and to which requests? How much am I spending per thousand requests, and on which models? Right now those are all shrugs.
Without measurement, the next two roadmap items, backpressure tuning and a benchmark harness, are guesswork. Observability is the dependency that unblocks them. So this increment is deliberately the cheapest thing that produces the data those steps need, and nothing more.
The metrics stack I rejected, and why
The obvious move is to reach for a real metrics stack: a Prometheus client
library, a /metrics endpoint in the text exposition format, histograms with
predefined buckets, a scrape config. That is the correct answer for a production
fleet, and the wrong answer here, for three reasons.
- It adds a dependency and a serving format whose value only appears once there is a Prometheus server, a Grafana instance, and a multi-host deployment to aggregate. None of that exists in a single-process learning proxy.
- Prometheus histograms commit you to fixed buckets up front. For a quick look at “what is my p95 right now,” a bounded ring buffer of raw samples is more honest and easier to reason about than bucket boundaries chosen blind.
- The interesting engineering here is not “wire up a library.” It is the set of small correctness decisions a metrics layer quietly gets wrong: percentile method, unknown-model cost handling, where latency is measured relative to batching, and how errors are accounted. A hand-rolled registry forces each of those into the open.
So the design is an in-process registry with a JSON snapshot. It is explicitly not durable, not multi-worker, and not a substitute for a real metrics pipeline. It is the measurement you need to make the next tuning decision, and it states its own limits. (This is the same instinct I described in a Staff AI Engineer interview: less model magic, more boring serving engineering.)
What real systems do, in miniature
The shape of this feature is borrowed from patterns that show up everywhere once you start measuring latency-sensitive services.
- Tail latency over averages. The canonical argument is Dean and Barroso, “The Tail at Scale.” The mean latency of a service is nearly useless because a small fraction of slow requests dominates the experience, especially when one user action fans out into many backend calls. Report percentiles, and care most about p95, p99, and the max, not the average.
- Quantile estimation on a stream. Systems that cannot store every sample use streaming quantile sketches (t-digest, HdrHistogram, DDSketch, Prometheus summaries). They trade a little accuracy for bounded memory. A bounded ring buffer is the simplest member of that family: it keeps exact samples for a recent window and is exact for that window by definition.
- Cost as a first-class metric. FinOps practice treats spend like latency, something you attribute per request, per model, per tenant, then watch over time. Token-based pricing makes this tractable because the upstream hands you the exact billable quantity, input and output tokens, in every response.
- RED and USE. Tom Wilkie’s RED method (rate, errors, duration) and Brendan Gregg’s USE method (utilization, saturation, errors) are the standard checklists for a request-serving system. This snapshot is a small RED surface: request rate (counts), errors, and duration (percentiles), plus the cost dimension that LLM serving adds on top.
Three ideas that hold the whole thing up
Percentiles by the nearest-rank method. A percentile answers “what latency is
this request faster than p percent of the recent window.” There are several
defensible definitions; this uses nearest-rank on the sorted window. For a window
of n ascending samples, the rank for percentile p is
round(p/100 * (n - 1)), clamped into [0, n - 1]. That makes p100 the max, and
makes every percentile of a single sample equal to that sample, with no
interpolation and no off-by-one at the edges. The cost is one sort per snapshot
call, which is fine because the window is bounded.
A bounded window, not all-time. All-time percentiles drift. A service that was
slow at startup and fast since then reports a misleadingly high p99 forever. A
bounded window, the last METRICS_LATENCY_WINDOW samples, reports recent behavior,
which is what you act on. The tradeoff is that a quiet service shows stale samples
until new traffic rolls them out. For a tuning tool, that is the right tradeoff.
Cost is an estimate, and it says so. The upstream reports exact token counts,
so the only source of error in the dollar figure is the pricing table, which is
static and maintained by hand. The dangerous failure mode is a model that is not
in the table silently contributing zero dollars, which makes spend look lower than
it is. The registry refuses that: unknown-model usage goes into a separate
unpriced bucket, never folded into the priced totals, and an estimate_complete
flag flips to false whenever any unpriced traffic exists. A wrong-but-confident
number is worse than a right-but-incomplete one.
Walking through the code
The feature is one module, app/metrics.py, plus a few lines of wiring in
app/main.py.
Pricing lookup with family matching
Model names arrive with version or date suffixes (claude-3-5-haiku-latest,
claude-3-5-haiku-20241022). The pricing table is keyed by family, and the lookup
matches the longest table key the model name starts with:
def _price_for(model: str):
name = (model or "").lower()
best = None
for key in PRICING_USD_PER_MTOK:
if name.startswith(key) and (best is None or len(key) > len(best)):
best = key
return PRICING_USD_PER_MTOK[best] if best is not None else None
Longest-prefix matching means a more specific family wins over a shorter one if
one is ever added, and unknown models cleanly return None instead of a guess.
The percentile helper
def _percentile(sorted_values, pct):
n = len(sorted_values)
if n == 1:
return sorted_values[0]
rank = int(round((pct / 100.0) * (n - 1)))
rank = max(0, min(n - 1, rank))
return sorted_values[rank]
It assumes a non-empty ascending list. The snapshot handles the empty case by
reporting null percentiles and a sample_count of zero, so a freshly started
proxy returns a well-formed snapshot instead of dividing by nothing.
The registry
Metrics holds counters, a deque(maxlen=window) of recent latencies, priced
token totals, a running cost, and the parallel unpriced accounting. Two record
methods cover the two outcomes:
record_success(model, input_tokens, output_tokens, latency_ms)always bumps the total and success counts and appends the latency. It then either adds to the priced totals and cost, or, for an unknown model, adds to the unpriced bucket. Both paths record latency, because a slow successful call is still a data point even if it is unpriced.record_error(latency_ms)bumps the total and error counts and records the latency, but touches no token or cost state, because a failed call before the upstream responds has no usage to bill.
Every mutation and the whole snapshot() copy happen under a threading.Lock. On
a single event loop the synchronous record methods cannot interleave, but the lock
costs almost nothing and keeps the registry safe under the threaded test client and
any future move to worker threads. The window size is clamped on construction
(max(1, min(window, 100000))) so a fat-fingered env value can never make the
per-snapshot sort expensive.
Wiring it in
main.py gains a METRICS_ENABLED flag (default off) and builds the registry in
the existing FastAPI lifespan only when enabled. The chat endpoint is refactored
so both the batcher path and the direct path run inside one try/except around a
perf_counter span:
start = time.perf_counter()
try:
if BATCH_ENABLED and _batcher is not None:
anthropic_json = await _batcher.submit(payload)
else:
anthropic_json = await _direct_dispatch(payload)
except Exception:
if _metrics is not None:
_metrics.record_error((time.perf_counter() - start) * 1000.0)
raise
latency_ms = (time.perf_counter() - start) * 1000.0
if _metrics is not None:
usage = anthropic_json.get("usage", {})
_metrics.record_success(model, usage.get("input_tokens", 0),
usage.get("output_tokens", 0), latency_ms)
This measures end-to-end request latency, the number the caller actually experiences. In batch mode that span includes the admission-window wait, which is the honest thing to report, because that wait is real latency the client paid. It is deliberately not labeled “upstream call latency,” since under batching one upstream call can serve several logical requests, and conflating the two would overstate per-request upstream timing.
The endpoint
@app.get("/metrics")
async def metrics():
if not METRICS_ENABLED or _metrics is None:
raise HTTPException(status_code=404, detail="Metrics are not enabled.")
return _metrics.snapshot()
When the feature is off the route returns 404 rather than an empty object. That keeps the default behavior identical to before the feature existed, and avoids exposing a traffic-and-spend surface on a public proxy unless the operator opts in.
Configuration
METRICS_ENABLED=0 # off by default; 1 enables collection and the endpoint
METRICS_LATENCY_WINDOW=1024 # recent samples kept for percentiles (clamped to 100000)
Testing without a network
tests/test_metrics.py is fully deterministic and never touches the network. It
drives the Metrics class directly to assert the math, and exercises the endpoint
through the app:
- Percentile edges: a single sample, and the nearest-rank values for a known 1..100 window (p50 is 51, p95 is 95, p99 is 99, p100 is 100).
- Pricing: family and dated-alias resolution, and
Nonefor unknown models. - Cost math: a known model where one million input and one million output tokens
produce an exact dollar figure, with
estimate_completetrue. - Unknown model: zero priced cost,
estimate_completefalse, and the usage landing in theunpricedbucket instead of the priced totals. - Error accounting: errors bump the error count and record latency but no tokens.
- Empty snapshot: null percentiles, zero counts, well-formed shape.
- Window clamping: zero clamps up to one, a billion clamps down to 100000.
- Endpoint: 404 by default, and after reloading the module with the flag set, a
mocked round trip produces
total: 1,success: 1, the right token total, and a positive estimated cost.
The default suite runs with the feature off, proving the existing realtime and
translation tests are untouched. The endpoint test reloads the module with
METRICS_ENABLED=1 to prove the enabled path, so both modes are covered.
Limitations, stated on purpose
- In process, single worker. Behind multiple workers each process has its own counters; a real deployment would aggregate them in a scrape target.
- Recent window, not history. Percentiles describe the bounded window, not all-time behavior.
- Cost is an estimate from a static table. Prices change and the table is updated by hand. Unknown models are surfaced, never silently zeroed.
- No durability. A restart resets every counter, which is correct for a tuning tool and wrong for billing. Billing-grade accounting belongs in the provider’s own usage records.
What it demonstrates
The same distributed-systems instincts from the last two posts, now pointed at self-measurement: tail-latency thinking, a streaming-quantile primitive picked for the actual scale, FinOps-style cost attribution, a RED surface, and the judgment to reject the heavyweight metrics stack until it earns its place. Plus the small refusals that keep numbers honest: the unpriced bucket, the 404 when disabled, the clamp on the window.
Visualization: observability.html ·
Repo: github.com/NeverTheSame/llm-batcher.
With the observatory in place, the remaining roadmap items become measurable
instead of asserted. Backpressure and concurrency tuning can be judged by their
effect on p95 and the error rate, and the benchmark harness can read real
throughput, latency, and cost out of /metrics under load. The tail latency stops
being a claim and starts being a graph.