L3 · 07 · LLMOps: Deploy, Observe, Optimise

Shipping an LLM feature is the start, not the end. Unlike normal code, it can regress without a deploy (model updates), cost can balloon quietly (token sprawl), and quality problems are invisible unless you measure them. LLMOps is the operational discipline: tracing, evals in production, caching, routing, cost control and guardrails.

Level 355 minTracks 3, 8Last verified 2026-10

By the end of this page you can

What breaks in production

FailureWhy it's sneakySignal
Quality regressionProvider silently updates the model under the same nameEval score drops on live traffic
Cost explosionA prompt grows, a loop spins, usage scales — all billed per tokenCost/request and daily spend charts
Latency spikesLong contexts, slow tool calls, big models on trivial tasksp50/p95/p99 per endpoint
Silent failuresModel returns plausible-wrong output; no exception is thrownOutput validation, user feedback, evals
Prompt driftSomeone edits a prompt, nobody re-ran evalsPrompt versioning + CI evals

Trace every call

You can't fix what you can't see. Log structured records per LLM call — and per tool call inside an agent run:

import json
import time
from contextlib import contextmanager


class Tracer:
    """Minimal LLM call tracer. In production, send spans to an observability backend
    (e.g. an OpenTelemetry collector, LangSmith, Langfuse, Braintrust)."""

    def __init__(self):
        self.records = []

    @contextmanager
    def span(self, name: str, **attrs):
        start = time.perf_counter()
        rec = {"span": name, **attrs}
        try:
            yield rec
        finally:
            rec["latency_ms"] = round((time.perf_counter() - start) * 1000, 1)
            self.records.append(rec)

    def report(self):
        for r in self.records:
            print(json.dumps(r))


if __name__ == "__main__":
    tr = Tracer()
    with tr.span("chat.completions", model="demo", feature="store-assistant") as span:
        time.sleep(0.01)                      # pretend to call the API
        span["prompt_tokens"] = 420
        span["completion_tokens"] = 95
        span["cost_usd"] = 0.0008
        span["finish_reason"] = "stop"
        span["status"] = "ok"
    tr.report()

Log the what (feature, model, token counts, latency, cost, finish_reason, status) and a trace ID that links the LLM call to the tool calls and the user request. Be careful with prompts/responses: they may contain PII — redact or sample, don't log raw user data by default.

Caching: three kinds

CacheWhat it savesNotes
Exact response cacheIdentical query → stored answerKey on (prompt, model, params). High hit rate on FAQs
Semantic cacheNear-identical query → stored answerEmbed the query, compare to cached. Needs a similarity threshold + staleness rules
Prompt caching (provider-side)Repeated prefix (system prompt, tool defs, docs) billed cheaperPut stable content first, variable content last — same rule as L2·03

For a store assistant, "what's your return policy" asked 500 times a day is one LLM call, not 500. But cache carefully: answers with personal or time-sensitive data shouldn't be served from a cache.

Routing: right model, right price

Not every call needs the frontier model. Route by task:

intent classification, extraction, short answers  -> small/fast model
complex reasoning, long documents, agent planning -> frontier model
refusals / safety checks                          -> fast, cheap model or code
embeddings                                        -> dedicated embedding model

Implement it as a thin layer in your client (L2·03's router pattern grows up): choose the model per request, log which was picked, and add a fallback — if the primary model errors or times out, retry a backup. Track quality per route so "we saved money by routing down" doesn't quietly become "we tanked accuracy".

Cost control

Guardrails in production

LayerCheck
InputLength limits, injection patterns flagged (L3·08), PII handling, rate limits per user
OutputSchema validation, citation checks (L2·06), forbidden-content filters, format checks
ActionConfirmation before writes, allow-listed tools, least-privilege per route
SystemFallback model on errors, kill switch per feature, circuit breaker on repeated failures
The kill switch is non-negotiable Every LLM feature needs a flag that turns it off (or degrades to a safe fallback) without a deploy. When the model starts hallucinating refunds at 2 a.m., "we'll push a fix tomorrow" isn't a plan.

The deploy-and-monitor loop

change(prompt/model) eval in CI(golden set) deploy %(canary) monitor liveevals + cost + latency feedbackinto golden set
Try it (60 min)
  1. Add Tracer-style logging to the Store Assistant: tokens, latency, cost, finish_reason per call.
  2. Add an exact-response cache. Run the same 5 queries twice — measure the savings.
  3. Route: trivial questions to a small local model, hard ones to a stronger one. Log the split.
  4. Set a daily cost budget and a kill-switch flag. Test that the flag actually disables the LLM path.

Key takeaways