← Back to DevBytes

How to Set Up Alerting for LLM Latency Spikes

How to Set Up Alerting for LLM Latency Spikes

Large Language Models (LLMs) have become the backbone of many modern applications, from customer support chatbots to code assistants. While accuracy and cost often dominate the conversation, latency is the metric that users feel most directly. When an LLM endpoint suddenly takes 30 seconds to respond instead of the usual 2 seconds, users abandon the session, downstream services time out, and trust evaporates. This tutorial walks you through everything you need to know to detect, alert on, and respond to LLM latency spikes before they become incidents.

What Is an LLM Latency Spike?

An LLM latency spike is a sudden, statistically significant increase in the time it takes for a model to process a request and return a response. Unlike a slow drift caused by gradual traffic growth, a spike is abrupt. It typically manifests as a sharp jump in metrics such as time-to-first-token (TTFT), total generation time, or end-to-end request duration measured at the application layer.

Latency in LLM systems is multi-layered. A single request passes through your application code, a load balancer, an API gateway, the model provider's inference infrastructure, and back. Each hop contributes to total latency. A spike could originate at any of these layers, which is why alerting must be granular enough to point you toward the root cause.

Why Alerting on Latency Spikes Matters

Key Metrics to Monitor

Before configuring alerts, you need to instrument your LLM calls to capture the right metrics. The four most important measurements are:

You should track these as both averages and percentiles. Averages hide tail latency, which is where spikes live. Focus on p95 and p99 values for alerting thresholds.

Instrumenting Your LLM Calls

The first step is wrapping your LLM client calls with timing instrumentation. Below is a Python example using a lightweight metrics collector that captures the key latency metrics for each request.

import time
import statistics
from dataclasses import dataclass, field
from collections import deque
from threading import Lock

@dataclass
class LatencyMetrics:
    ttft_samples: deque = field(default_factory=lambda: deque(maxlen=1000))
    total_duration_samples: deque = field(default_factory=lambda: deque(maxlen=1000))
    token_rate_samples: deque = field(default_factory=lambda: deque(maxlen=1000))
    _lock: Lock = field(default_factory=Lock)

    def record(self, ttft: float, total_duration: float, tokens_generated: int):
        with self._lock:
            self.ttft_samples.append(ttft)
            self.total_duration_samples.append(total_duration)
            if total_duration > ttft and tokens_generated > 0:
                rate = tokens_generated / (total_duration - ttft)
                self.token_rate_samples.append(rate)

    def percentile(self, samples: deque, p: float) -> float:
        if not samples:
            return 0.0
        sorted_samples = sorted(samples)
        index = int(len(sorted_samples) * p / 100)
        index = min(index, len(sorted_samples) - 1)
        return sorted_samples[index]

    def get_snapshot(self) -> dict:
        with self._lock:
            return {
                "ttft_p95": self.percentile(self.ttft_samples, 95),
                "ttft_p99": self.percentile(self.ttft_samples, 99),
                "duration_p95": self.percentile(self.total_duration_samples, 95),
                "duration_p99": self.percentile(self.total_duration_samples, 99),
                "token_rate_p5": self.percentile(self.token_rate_samples, 5),
                "sample_count": len(self.total_duration_samples),
            }

# Global metrics instance
metrics = LatencyMetrics()

def call_llm_with_metrics(client, prompt: str, model: str = "gpt-4") -> str:
    start_time = time.monotonic()
    first_token_time = None
    token_count = 0

    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        stream=True,
    )

    full_response = []
    for chunk in response:
        if chunk.choices and chunk.choices[0].delta.content:
            if first_token_time is None:
                first_token_time = time.monotonic()
            content = chunk.choices[0].delta.content
            full_response.append(content)
            token_count += 1

    total_duration = time.monotonic() - start_time
    ttft = (first_token_time - start_time) if first_token_time else total_duration

    metrics.record(
        ttft=ttft,
        total_duration=total_duration,
        tokens_generated=token_count,
    )

    return "".join(full_response)

This instrumentation captures per-request timing and maintains a rolling window of samples. The get_snapshot method gives you a point-in-time view of your latency percentiles, which is what your alerting logic will evaluate.

Building the Alerting Engine

With metrics in place, you need an alerting engine that evaluates thresholds and triggers notifications. The design below uses a sliding window approach with configurable thresholds and cooldown periods to prevent alert fatigue.

import time
import smtplib
from email.mime.text import MIMEText
from dataclasses import dataclass
from typing import Callable, List

@dataclass
class AlertRule:
    name: str
    metric_key: str
    threshold: float
    comparison: str  # "gt" or "lt"
    window_seconds: int
    min_samples: int
    cooldown_seconds: int
    last_fired: float = 0.0

@dataclass
class Alert:
    rule_name: str
    metric_value: float
    threshold: float
    timestamp: float
    message: str

class LatencyAlerter:
    def __init__(self, metrics_collector):
        self.metrics = metrics_collector
        self.rules: List[AlertRule] = []
        self.alert_history: List[Alert] = []
        self.notification_channels: List[Callable] = []

    def add_rule(self, rule: AlertRule):
        self.rules.append(rule)

    def add_notification_channel(self, channel: Callable[[Alert], None]):
        self.notification_channels.append(channel)

    def evaluate(self) -> List[Alert]:
        snapshot = self.metrics.get_snapshot()
        fired_alerts = []
        now = time.time()

        for rule in self.rules:
            if rule.metric_key not in snapshot:
                continue

            value = snapshot[rule.metric_key]
            if snapshot.get("sample_count", 0) < rule.min_samples:
                continue

            condition_met = (
                value > rule.threshold if rule.comparison == "gt"
                else value < rule.threshold
            )

            if condition_met and (now - rule.last_fired) > rule.cooldown_seconds:
                alert = Alert(
                    rule_name=rule.name,
                    metric_value=value,
                    threshold=rule.threshold,
                    timestamp=now,
                    message=(
                        f"[LATENCY ALERT] {rule.name}: "
                        f"{rule.metric_key} = {value:.2f}s "
                        f"(threshold: {rule.threshold:.2f}s)"
                    ),
                )
                fired_alerts.append(alert)
                self.alert_history.append(alert)
                rule.last_fired = now

                for channel in self.notification_channels:
                    try:
                        channel(alert)
                    except Exception as e:
                        print(f"Notification channel failed: {e}")

        return fired_alerts

The alerter evaluates each rule against the current metrics snapshot. The min_samples field prevents false alerts when the system has only processed a handful of requests, and the cooldown_seconds field ensures you do not get spammed with repeated alerts for the same ongoing condition.

Defining Alert Rules

Choosing the right thresholds is critical. Set them too low and you will drown in noise. Set them too high and you will miss real problems. A good starting strategy is to baseline your metrics for one to two weeks, then set thresholds at roughly 2x your p95 values.

alerter = LatencyAlerter(metrics)

# Alert if p99 time-to-first-token exceeds 5 seconds
alerter.add_rule(AlertRule(
    name="TTFT Spike",
    metric_key="ttft_p99",
    threshold=5.0,
    comparison="gt",
    window_seconds=300,
    min_samples=20,
    cooldown_seconds=300,
))

# Alert if p95 total duration exceeds 15 seconds
alerter.add_rule(AlertRule(
    name="Total Duration Spike",
    metric_key="duration_p95",
    threshold=15.0,
    comparison="gt",
    window_seconds=300,
    min_samples=20,
    cooldown_seconds=300,
))

# Alert if token generation rate drops below 10 tokens/sec
alerter.add_rule(AlertRule(
    name="Slow Token Generation",
    metric_key="token_rate_p5",
    threshold=10.0,
    comparison="lt",
    window_seconds=300,
    min_samples=20,
    cooldown_seconds=600,
))

Setting Up Notification Channels

An alert that nobody sees is useless. You should route latency alerts to the channels your team actively monitors. Below are implementations for two common channels: Slack webhooks and email.

Slack Notifications

import json
import urllib.request

def make_slack_notifier(webhook_url: str):
    def notifier(alert: Alert):
        payload = {
            "text": alert.message,
            "attachments": [{
                "color": "danger",
                "fields": [
                    {"title": "Rule", "value": alert.rule_name, "short": True},
                    {"title": "Value", "value": f"{alert.metric_value:.2f}s", "short": True},
                    {"title": "Threshold", "value": f"{alert.threshold:.2f}s", "short": True},
                    {"title": "Timestamp", "value": str(alert.timestamp), "short": True},
                ],
            }],
        }
        data = json.dumps(payload).encode("utf-8")
        req = urllib.request.Request(
            webhook_url,
            data=data,
            headers={"Content-Type": "application/json"},
        )
        urllib.request.urlopen(req)
    return notifier

slack_notifier = make_slack_notifier("https://hooks.slack.com/services/YOUR/WEBHOOK/URL")
alerter.add_notification_channel(slack_notifier)

Email Notifications

def make_email_notifier(smtp_host: str, smtp_port: int, sender: str, recipients: list):
    def notifier(alert: Alert):
        msg = MIMEText(alert.message)
        msg["Subject"] = f"LLM Latency Alert: {alert.rule_name}"
        msg["From"] = sender
        msg["To"] = ", ".join(recipients)

        with smtplib.SMTP(smtp_host, smtp_port) as server:
            server.sendmail(sender, recipients, msg.as_string())
    return notifier

email_notifier = make_email_notifier(
    smtp_host="smtp.yourcompany.com",
    smtp_port=587,
    sender="alerts@yourcompany.com",
    recipients=["oncall@yourcompany.com"],
)
alerter.add_notification_channel(email_notifier)

Running the Alerting Loop

The alerting engine needs to run on a schedule. A common pattern is a background thread that evaluates rules every 30 to 60 seconds. Here is a complete loop that ties everything together:

import threading
import time

def alerting_loop(alerter: LatencyAlerter, interval: int = 60):
    def run():
        while True:
            try:
                alerts = alerter.evaluate()
                for alert in alerts:
                    print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {alert.message}")
            except Exception as e:
                print(f"Alerting loop error: {e}")
            time.sleep(interval)

    thread = threading.Thread(target=run, daemon=True)
    thread.start()
    return thread

# Start the alerting loop
alert_thread = alerting_loop(alerter, interval=60)

# Simulate traffic
from openai import OpenAI
client = OpenAI()

while True:
    try:
        call_llm_with_metrics(client, "Summarize the benefits of monitoring.")
    except Exception as e:
        print(f"LLM call failed: {e}")
    time.sleep(1)

Integrating with Observability Platforms

While a custom alerting engine is useful for understanding the mechanics, most production systems integrate with established observability platforms. Below is an example of exporting your LLM latency metrics to Prometheus, which pairs naturally with Alertmanager for routing.

from prometheus_client import CollectorRegistry, Gauge, start_http_server

registry = CollectorRegistry()

ttft_p95 = Gauge(
    "llm_ttft_p95_seconds",
    "95th percentile time to first token",
    registry=registry,
)
ttft_p99 = Gauge(
    "llm_ttft_p99_seconds",
    "99th percentile time to first token",
    registry=registry,
)
duration_p95 = Gauge(
    "llm_duration_p95_seconds",
    "95th percentile total request duration",
    registry=registry,
)
duration_p99 = Gauge(
    "llm_duration_p99_seconds",
    "99th percentile total request duration",
    registry=registry,
)
token_rate_p5 = Gauge(
    "llm_token_rate_p5",
    "5th percentile token generation rate (tokens/sec)",
    registry=registry,
)

def export_metrics_to_prometheus(metrics_collector):
    snapshot = metrics_collector.get_snapshot()
    ttft_p95.set(snapshot["ttft_p95"])
    ttft_p99.set(snapshot["ttft_p99"])
    duration_p95.set(snapshot["duration_p95"])
    duration_p99.set(snapshot["duration_p99"])
    token_rate_p5.set(snapshot["token_rate_p5"])

# Start Prometheus metrics endpoint
start_http_server(8000, registry=registry)

# In your alerting loop, also export metrics
def alerting_loop_with_prometheus(alerter, metrics, interval=60):
    while True:
        export_metrics_to_prometheus(metrics)
        alerter.evaluate()
        time.sleep(interval)

With metrics exported to Prometheus, you define alerting rules in a YAML configuration file that Alertmanager evaluates:

# prometheus_alerts.yml
groups:
  - name: llm_latency_alerts
    rules:
      - alert: LLMTTFTSpike
        expr: llm_ttft_p99_seconds > 5
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "LLM time-to-first-token p99 exceeds 5 seconds"
          description: "TTFT p99 is {{ $value }}s on {{ $labels.instance }}"

      - alert: LLMTotalDurationSpike
        expr: llm_duration_p95_seconds > 15
        for: 3m
        labels:
          severity: critical
        annotations:
          summary: "LLM total duration p95 exceeds 15 seconds"
          description: "Duration p95 is {{ $value }}s"

      - alert: LLMSlowTokenGeneration
        expr: llm_token_rate_p5 < 10
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "LLM token generation rate dropped below 10 tokens/sec"

The for field in each rule adds a sustained-condition requirement, meaning the threshold must be breached continuously for the specified duration before the alert fires. This filters out transient blips.

Best Practices

Conclusion

Setting up alerting for LLM latency spikes is a multi-step process that begins with proper instrumentation, moves through thoughtful threshold design, and culminates in a reliable notification pipeline. By tracking the right metrics — time to first token, total duration, and token generation rate — at the percentile level, you gain visibility into the tail latency that actually affects users. Combining a custom alerting engine with established platforms like Prometheus and Alertmanager gives you both flexibility and production-grade reliability. The key is to treat latency as a first-class signal, tune your thresholds against real baselines, and ensure that every alert is actionable, contextual, and routed to someone who can respond. With these practices in place, you can catch latency spikes early, protect your user experience, and prevent minor slowdowns from escalating into full-scale incidents.

— Ad —

Google AdSense will appear here after approval

← Back to all articles