The LLM StackFrom Silicon to Agents
Part XII — Production, Systems & MLOps
31 min read·Updated ·▶ Run the code (Colab)

12.3 Caching, Routing & Cost Control in Production

LLM inference is expensive. A single frontier-model call can cost anywhere from a fraction of a cent to several dollars depending on context length and model tier, and at even modest traffic (say, ten thousand daily active users sending five messages each) you are looking at meaningful infrastructure spend before you have written a line of business logic. This chapter is about systematically engineering that bill down — not by compromising quality, but by routing work to the cheapest system that can do it well, avoiding redundant computation wherever possible, and squeezing utilisation out of every GPU-second you pay for.

The techniques here sit at the intersection of distributed systems, economics, and ML: semantic caching, prompt-prefix caching, model routing cascades, speculative routing, quantised fallbacks, intelligent batching, and spot/preemptible GPU scheduling. We will cover the mechanism of each, when to reach for it, and how to wire them together into a coherent cost-control stack.

Cross-references: Inference Economics: Latency, Throughput & Cost covers the per-token cost model; Prefix Caching & KV-Cache Reuse covers the low-level KV-cache reuse mechanism; Continuous Batching & Request Scheduling covers the scheduler side of batching; Quantization I and Quantization II cover the quantisation methods we invoke here as fallbacks. When we serve the Stack-100M model we build in Part XIV, the levers in this chapter are exactly the ones we pull — see Evaluation & Serving: Honest Benchmarks, int4 Quantization, and Running on a Laptop.


Why Does This Problem Exist?

Token economics follow a simple formula. Let \(C_\text{input}\) and \(C_\text{output}\) be per-token prices (in USD), and let a request have \(n_p\) prompt tokens and \(n_g\) generated tokens:

\[ \text{cost per request} = C_\text{input} \cdot n_p + C_\text{output} \cdot n_g \]

Output tokens are typically 3–5× more expensive than input tokens because decoding is memory-bandwidth-bound and fundamentally sequential (see The Anatomy of LLM Inference: Prefill, Decode & The KV Cache). For a 128-token system prompt plus 500-token user message feeding a 300-token response, and illustrative prices of $0.003/1K input and $0.015/1K output, one call costs roughly:

\[ \frac{628 \cdot 0.003 + 300 \cdot 0.015}{1000} \approx \$0.0064 \]

That is small. But at 50,000 calls per day the monthly bill is around USD 9,600 — and that is a single, modest product. Production applications routinely run at 10× to 100× that volume, and frontier models are significantly pricier. The levers are: (a) call fewer tokens, (b) reuse previously computed results, © route to a cheaper model when you can, (d) spread load to reduce idle GPU time.

WHERE THE MONEY GOES IN ONE REQUEST cost = C_in x n_p + C_out x n_g (area of each block below is proportional to its cost contribution) input / prompt tokens (n_p ~ 628) x 1x unit price output / generated tokens (n_g ~ 300) x ~5x unit price ~$0.0019 ~$0.0045 total ~ $0.0064 per request (illustrative) output tokens are 3-5x pricier - decode is sequential and memory-bandwidth-bound, so it dominates despite fewer tokens FOUR LEVERS ON THE BILL (a) call fewer tokens shorter prompts, less generation (b) reuse computed results -> exact / semantic / prefix caching (c) route to a cheaper model -> cascades / speculative / quantised (d) fill idle GPU time -> batching / spot fleets THE BILL $
Where the money goes in one request, and the four levers that pull it down. Output tokens are priced roughly 5x higher than input tokens because decoding is sequential and memory-bandwidth-bound, so the output block's area rivals the input block's despite carrying fewer than half the tokens. The rest of this chapter is organized around the four levers on the right: use fewer tokens, reuse prior computation (caching), route to a cheaper model (cascades, speculative routing, quantised fallbacks), or fill idle GPU time (batching, spot fleets).

Exact Caching

The simplest optimisation is to remember the answer to a query you have already answered. If you can guarantee that two requests are byte-for-byte identical, you can return the cached response with zero model compute.

What to key on

The cache key must cover everything that would change the model’s output:

  • Model ID (and version/commit, not just name)
  • Full serialised messages array (role + content)
  • Sampling hyperparameters (temperature, top-p, top-k, max tokens)
  • Any injected system prompt variables

A common mistake is to key only on the user message and miss that the system prompt varies per tenant, producing cross-tenant cache poisoning.

Storage and eviction

Redis with a TTL is the standard choice. A SHA-256 hash of the canonicalised request body fits in 32 bytes; the response blob is typically 1–10 KB. With a 90-day TTL and 50,000 RPD the steady-state working set is on the order of a few hundred megabytes — trivially cacheable.

import hashlib, json, redis

client = redis.Redis(host="localhost", port=6379, decode_responses=False)

def _cache_key(model: str, messages: list[dict], params: dict) -> str:
    """
    Deterministic SHA-256 key over the full request.
    sort_keys=True ensures dict ordering never matters.
    """
    payload = json.dumps(
        {"model": model, "messages": messages, **params},
        sort_keys=True, ensure_ascii=False,
    ).encode("utf-8")
    return "llm:exact:" + hashlib.sha256(payload).hexdigest()

def exact_cache_get(model, messages, params, ttl_seconds=86400 * 30):
    key = _cache_key(model, messages, params)
    blob = client.get(key)
    if blob is not None:
        return json.loads(blob)          # cache hit: no model call
    return None

def exact_cache_set(model, messages, params, response, ttl_seconds=86400 * 30):
    key = _cache_key(model, messages, params)
    # SETEX: set with expiry atomically
    client.setex(key, ttl_seconds, json.dumps(response))

Exact caching has a narrow hit rate for conversational workloads (few requests are truly identical) but extremely high value for specific patterns: FAQ bots, templated document generation, CI/CD pipeline prompts, and embedding calls (which are purely deterministic). For embeddings, exact caching can eliminate 60–90% of API calls if users resubmit the same documents.

Handling non-determinism

If temperature > 0, returning a cached stale response is semantically wrong for freshness-sensitive queries. A common compromise is to cache only when temperature == 0, or to cache with a short TTL (a few hours) to capture burst traffic while not serving stale creative content for long.


Semantic Caching

Exact caching misses near-duplicate queries. Semantic caching embeds the user query and checks whether the embedding is close enough to a previously answered query that the cached answer is still valid.

User query embed() ~1-5 ms (local 22M ST model) ANN search FAISS / Qdrant ~1-10 ms cos sim s >= tau? tau approx 0.92-0.95; s = q-hat . c-hat yes (hit) Return cached response fast path: ~2-15 ms total no (miss) Call LLM 500 ms - 5 s (~100x slower) Store (embedding, response) populate index
Semantic cache lookup flow. The query is embedded (~1-5 ms), compared against stored entries via ANN search (~1-10 ms), and a cosine threshold test (s = q-hat dot c-hat >= tau) decides hit or miss. A cache hit returns the stored response in ~2-15 ms total -- roughly 100x faster than calling the LLM (~500 ms-5 s); on a miss the LLM is called and the new (embedding, response) pair is stored back into the index for future queries.

Similarity threshold selection

Let \(\hat{q}\) be the unit-normalised query embedding and \(\hat{c}\) be a cached entry’s embedding. The cosine similarity is \(s = \hat{q} \cdot \hat{c}\). You admit a cache hit when \(s \geq \tau\).

Choosing \(\tau\) is a calibration problem. Too low: you return wrong answers (a FAQ about “cancel subscription” matches “delete my account” — maybe OK) or dangerously wrong ones (“What is the dosage of aspirin” matches “What is the dosage of ibuprofen” — very much not OK). Too high: the hit rate collapses toward exact matching.

A practical approach:

  1. Collect a held-out set of (query, gold answer) pairs from your domain.
  2. For each \(\tau \in [0.85, 0.99]\) sweep, measure precision (fraction of returned hits that are semantically correct) and recall (fraction of queries served from cache).
  3. Pick the \(\tau\) that hits your precision floor (typically 0.97+) while maximising recall.

For general chat bots a threshold near 0.92–0.95 is common. For medical, legal, or financial applications a higher bar (0.97+) or a human-review loop on borderline hits is appropriate.

That domain gap is not arbitrary — it falls out of a decision-theoretic view of \(\tau\). Let \(h(\tau)\) be the hit rate, \(e(\tau)\) the fraction of admitted hits that are semantically wrong, \(c_\text{call}\) the money a hit saves, and \(c_\text{err}\) the cost of serving a wrong answer (refund, escalation, harm). Expected saving per query is

\[ S(\tau) = h(\tau)\,\Big[\big(1 - e(\tau)\big)\,c_\text{call} - e(\tau)\,c_\text{err}\Big] \]

which is positive only when the admitted-hit precision satisfies \(1 - e(\tau) > \dfrac{c_\text{err}}{c_\text{call} + c_\text{err}}\). With \(c_\text{err} \approx c_\text{call}\) you need barely 50% precision and can run a loose threshold; with \(c_\text{err} = 30 c_\text{call}\) you need better than 96.8% precision, which is precisely why regulated domains land at 0.97+ or add a review loop. Sweep \(\tau\), measure \(h\) and \(e\) on held-out data, and pick the \(\tau\) that maximises \(S\) rather than the one that maximises hit rate.

Choosing the semantic-cache threshold tau high low precision / recall 0.85 ~0.93 1.00 cosine-similarity threshold (tau) precision floor (e.g. 0.97) precision (fraction of hits that are correct) recall / cache hit rate benign near-miss: "cancel subscription" ~ "delete my account" false hits "aspirin dosage" ~ "ibuprofen dosage" -> wrong & unsafe chosen tau zone general chat ~0.92-0.95; medical / legal / financial ~0.97+ high tau: hit rate collapses toward exact match -- cache barely fires
The semantic-cache threshold tau trades precision against recall: raising tau reduces wrong cache hits but shrinks the hit rate toward exact matching. Pick the lowest tau that still clears your domain's precision floor — around 0.92-0.95 for general chat, 0.97+ (or a human-review loop) for medical, legal, or financial applications where a false hit is dangerous, not just annoying.
import numpy as np
from typing import Optional

class SemanticCache:
    """
    Minimal semantic cache using cosine similarity.
    In production, replace the linear scan with a FAISS/Qdrant ANN index.
    """
    def __init__(self, embed_fn, threshold: float = 0.93):
        self.embed_fn = embed_fn      # callable: str -> np.ndarray (unit-normed)
        self.threshold = threshold
        self.index: list[tuple[np.ndarray, dict]] = []  # (embedding, entry)

    def _cosine(self, a: np.ndarray, b: np.ndarray) -> float:
        return float(np.dot(a, b))    # assumes unit-norm inputs

    def get(self, query: str) -> Optional[dict]:
        """Return the best cached entry if similarity >= threshold."""
        if not self.index:
            return None
        q_emb = self.embed_fn(query)
        best_score, best_entry = max(
            ((self._cosine(q_emb, emb), entry) for emb, entry in self.index),
            key=lambda x: x[0],
        )
        if best_score >= self.threshold:
            return best_entry          # cache hit
        return None

    def put(self, query: str, response: dict) -> None:
        """Store a new (query, response) pair."""
        q_emb = self.embed_fn(query)
        self.index.append((q_emb, {"query": query, "response": response}))

Embedding model selection and latency

The embedding call itself introduces latency. A 100 ms embedding call eats into the savings if the cached path is supposed to be fast. Use a small, locally-hosted embedding model (e.g., a 22M-parameter sentence-transformer) so the embedding call completes in 1–5 ms on CPU. The ANN lookup in a vector store like Qdrant or FAISS is another 1–10 ms at typical scales. Compare that to a frontier model call at 500 ms to 5 s: the speedup is 100×.

In production you rarely hand-roll this. GPTCache (Zilliz) wraps the whole pattern — embedding function, vector backend (FAISS, Qdrant, Milvus), similarity evaluator, eviction policy — behind a drop-in wrapper around the OpenAI/LangChain client, and LiteLLM (below) exposes both an exact Redis cache and a vector-store-backed semantic cache at the gateway. Build the toy version above once to understand the failure modes, then adopt a library so you inherit eviction, TTL, and hit-rate metrics for free.

Semantic caching integrates naturally with RAG systems (see Retrieval-Augmented Generation Architectures) — you can cache at both the retrieval step and the generation step.


Prompt-Prefix Caching (Provider-Side KV Reuse)

Distinct from the application-level caches above, providers like Anthropic (prompt caching) and OpenAI (caching) offer server-side KV-cache reuse for repeated prompt prefixes. If you send a 2,000-token system prompt on every call, the provider can skip recomputing the key-value tensors for that prefix after the first request.

The economics are significant. Anthropic’s prompt caching charges roughly 10% of the normal input price for cache-hit tokens — a rate that has held steady through 2026 — plus a modest premium (roughly 1.25× to 2× the base input price, depending on cache TTL) on the initial write. For a 2,000-token system prompt at $0.003/1K tokens:

  • Without caching: 2,000 tokens × $0.003/1K = $0.006 per call
  • With caching (after first call): 2,000 tokens × $0.0003/1K = $0.0006 per call

At 10,000 calls per day this saves approximately USD 18 per day on the system prompt alone — over USD 6,500 per year.

Worked example: prompt caching savings

Scenario: a coding assistant with a 4,000-token system prompt (instructions + code style guide) and an average 800-token user message generating 400-token responses. Traffic: 20,000 calls/day. Prices (illustrative): $0.003/1K input, $0.0003/1K cached input, $0.015/1K output.

Without prompt caching:

\[ \text{daily cost} = 20{,}000 \times \frac{4800 \times 0.003 + 400 \times 0.015}{1000} = 20{,}000 \times (0.0144 + 0.006) = 20{,}000 \times 0.0204 = \$408/\text{day} \]

With prompt caching (system prompt hits cache 95% of the time):

\[ \text{daily cost} \approx 20{,}000 \times \frac{4000 \times 0.95 \times 0.0003 + 800 \times 0.003 + 400 \times 0.015}{1000} \]
\[ = 20{,}000 \times (0.00114 + 0.0024 + 0.006) = 20{,}000 \times 0.00954 = \$190.80/\text{day} \]

Saving: roughly $217/day or $79K/year — just from restructuring your prompt.

To maximise prefix cache hits, keep the stable part of your prompt at the top (system instructions, few-shot examples, retrieved context) and put the variable part at the bottom (user message). This is covered in more depth in Prefix Caching & KV-Cache Reuse and Context Engineering & Management.

Stable-first (cache-friendly) stable prefix: system instructions + few-shot + retrieved context ~2000 tokens variable: user message ~500 tokens cache breakpoint KV cache: reused KV — billed ~10% of input price recomputed — full price Variable-first (cache-hostile) variable: user message ~500 tokens stable content: system instructions + few-shot + retrieved context ~2000 tokens cache breakpoint (early!) KV cache: tiny reuse recomputed — full input price (almost the whole prompt) The cache hit begins at the first token that differs. Everything before it is near-free; everything after is recomputed — so put the stable content on top.
Provider-side prompt-prefix caching reuses KV tensors only up to the first token that differs from a previous request. Placing the stable content (system prompt, few-shot examples, retrieved context) first and the variable user message last maximizes the region billed at the cheap cached-read rate; interleaving or front-loading the variable part moves the cache breakpoint early and forces near-full-price recomputation of almost the whole prompt.
# Anthropic prompt caching API usage (Python SDK, 2024+)
import anthropic

client = anthropic.Anthropic()

# Mark the static system prompt for caching.
# The provider will reuse KV tensors for this block across calls.
response = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=1024,
    system=[
        {
            "type": "text",
            "text": STATIC_SYSTEM_PROMPT,          # 4000+ tokens
            "cache_control": {"type": "ephemeral"}, # request caching
        }
    ],
    messages=[
        {"role": "user", "content": user_message}  # variable part
    ],
)

# Inspect whether you got a cache hit
usage = response.usage
print(f"Input tokens: {usage.input_tokens}")
print(f"Cache read tokens: {usage.cache_read_input_tokens}")   # billed at 10%
print(f"Cache write tokens: {usage.cache_creation_input_tokens}")  # first call

Self-hosted prefix caching (vLLM, SGLang)

If you serve your own weights — a quantised 7B fallback tier, or the Stack-100M model of Part XIV — you get the same mechanism without any provider, because the open-source servers implement it directly. vLLM calls it automatic prefix caching (APC): KV blocks are hashed by their token contents so any request sharing a prefix reuses them; SGLang calls it RadixAttention and keeps the KV blocks in a radix tree so branching prefixes (few-shot templates, multi-turn threads, parallel samples of the same prompt) share ancestors.

# vLLM: automatic prefix caching. Enabled by default in recent (V1-engine)
# releases; the flag is written out explicitly here so the intent is visible.
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --enable-prefix-caching \
  --gpu-memory-utilization 0.90

# SGLang: RadixAttention is on by default. Disable it to A/B measure the win.
python -m sglang.launch_server \
  --model-path meta-llama/Llama-3.1-8B-Instruct \
  --port 30000
  # add --disable-radix-cache for the no-cache control run

Two differences from the provider case matter operationally. First, the saving is not a line item on an invoice: a hit removes prefill FLOPs you would otherwise buy with GPU-seconds, so it shows up as lower time-to-first-token and higher tokens/second per GPU — you must divide it into your own cost-per-token model. Second, the cache lives in the same GPU memory as the KV cache for running requests and is LRU-evicted, so the hit rate is a function of (working-set of distinct prefixes) versus (free KV blocks): raising --gpu-memory-utilization or shrinking --max-model-len buys hit rate. Both servers export prefix-cache hit counters on their Prometheus /metrics endpoint; treat that number as a first-class dashboard tile alongside your application cache hit rate. Mechanism details are in vLLM: Architecture, PagedAttention & Internals and SGLang: RadixAttention & Structured Programs.


Model Routing and Cascades

Not every query needs your most capable (and most expensive) model. A cascade routes each request to the cheapest model that can answer it correctly, escalating to stronger models only when needed.

cheaper more expensive Classifier (fast) classify query simple? no Small model 7B / INT4 cheap, fast confident? no Large model 70B / FP16 frontier quality yes Return directly yes Return directly always Return (final tier, unconditional) E[cost] = c_s + (1-p_s)*c_l; at 70% hit rate: ~2.5x cheaper than always using the large model
Model routing cascade with sequential escalation. Each request is evaluated by the cheapest stage first: a fast classifier returns directly for simple queries; failures escalate to the small 7B model (which returns if confident); only the remainder reach the large 70B frontier model. At a 70% classifier hit rate the expected cost is approximately 2.5x lower than calling the large model for every request.

The cascade can be implemented in two ways:

1. Quality-based escalation (sequential)

Call the cheap model first; if its output meets a quality gate, return it. Otherwise call the expensive model. This introduces latency for the escalated fraction, so it is best for workloads where most queries are simple (high hit rate on the cheap tier).

import asyncio
from dataclasses import dataclass
from typing import Callable, Awaitable

@dataclass
class ModelTier:
    name: str
    call_fn: Callable[..., Awaitable[dict]]   # async function
    cost_per_1k_tokens: float                 # illustrative combined cost
    quality_gate: Callable[[dict], bool]      # returns True iff output is good enough

async def cascade(prompt: str, tiers: list[ModelTier]) -> dict:
    """
    Try each tier in order (cheapest first).
    Return the first response that passes its quality gate,
    or the last tier's response unconditionally.
    """
    for i, tier in enumerate(tiers):
        response = await tier.call_fn(prompt)
        is_last = (i == len(tiers) - 1)
        if is_last or tier.quality_gate(response):
            response["_tier_used"] = tier.name
            return response
    # unreachable, but satisfies type checker
    raise RuntimeError("Empty tier list")

# ------- Example quality gates -------

def confidence_gate(response: dict, min_logprob: float = -0.15) -> bool:
    """
    Accept the cheap model's output if average log-probability of
    output tokens is high (model is 'confident').
    Requires logprobs=True in the API call.
    """
    logprobs = response.get("logprobs", [])
    if not logprobs:
        return False
    avg = sum(logprobs) / len(logprobs)
    return avg >= min_logprob

def length_gate(response: dict, max_tokens: int = 200) -> bool:
    """
    Reject cheap model if it hit the token limit — likely incomplete.
    """
    return response.get("finish_reason") != "length"

2. Classifier-based routing (parallel or pre-dispatch)

Train a small classifier that predicts which model tier a query belongs to, and route before calling any LLM. This avoids the latency overhead of sequential calls but requires labelled training data (which you can bootstrap from cascade logs: label the query with the cheapest tier that gave good output in the sequential cascade).

# Lightweight routing classifier using a small embedding model + logistic regression.
# In practice you might use a fine-tuned DistilBERT or even a rule-based system.

import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import LabelEncoder

class RoutingClassifier:
    """
    Maps a query embedding to a model tier label.
    Train offline on cascade logs; serve online with ~1 ms latency.
    """
    def __init__(self, embed_fn, labels: list[str]):
        self.embed_fn = embed_fn
        self.enc = LabelEncoder().fit(labels)
        self.clf = LogisticRegression(max_iter=500, C=1.0)

    def fit(self, queries: list[str], tier_labels: list[str]) -> None:
        X = np.stack([self.embed_fn(q) for q in queries])
        y = self.enc.transform(tier_labels)
        self.clf.fit(X, y)

    def predict(self, query: str) -> tuple[str, float]:
        """Returns (tier_name, confidence)."""
        emb = self.embed_fn(query).reshape(1, -1)
        proba = self.clf.predict_proba(emb)[0]
        idx = int(np.argmax(proba))
        return self.enc.inverse_transform([idx])[0], float(proba[idx])

Cascade economics

Let \(p_s\) be the fraction of queries routed to the small model (hit rate), \(c_s\) the small-model cost per query, and \(c_l\) the large-model cost per query. Expected cost per query:

\[ \mathbb{E}[\text{cost}] = p_s \cdot c_s + (1 - p_s) \cdot (c_s + c_l) = c_s + (1-p_s) \cdot c_l \]

If the small model handles 70% of traffic (on the sequential cascade that also pays \(c_s\) before escalating), and \(c_s = \$0.002\), \(c_l = \$0.020\):

\[ \mathbb{E}[\text{cost}] = 0.002 + 0.30 \times 0.020 = 0.002 + 0.006 = \$0.008 \]

Versus paying \(c_l\) for everything: USD 0.020. A 2.5× cost reduction for a 70% hit rate.


Speculative Routing

Speculative routing is the routing analogue of speculative decoding (see Speculative Decoding: Draft Models, Medusa, EAGLE & Lookahead). Instead of waiting for a sequential cascade decision, you fire both the cheap and expensive models in parallel and discard the expensive result if the cheap one passes the quality gate. This cuts latency to approximately the cheap model’s latency for the common case, while guaranteeing large-model quality for the rest.

Query concurrent fan-out Small model (fast) finishes first 3-10x faster Large model (slow) runs concurrently higher cost gate: pass? yes return (small result) ~small-model latency cancel large (stop paying) no use large result latency = large model (small gate failed) Best when small is 3-10x faster, gate is cheap, and cancellation saves long outputs.
Speculative routing: parallel small and large models with cancellation. Both models fire concurrently from the same query. If the small (fast) model passes the quality gate, the large model is cancelled immediately -- stopping token spend -- and the small result is returned at ~small-model latency. Only when the gate fails does the system fall through to the large model's result. The dashed red cancel arrow is the key economic lever: streaming cancellation prevents paying for tokens that will not be used.

The economics are worse than sequential cascade (you always pay both) unless the large model can be cancelled mid-generation when the small model succeeds. Streaming APIs with cancellation make this viable.

import asyncio

async def speculative_route(query: str, small_fn, large_fn, gate_fn):
    """
    Fires both models concurrently. Returns the small model's output if it
    passes the gate; otherwise waits for (and returns) the large model.
    Cancels the large model task if small passes early.
    """
    small_task = asyncio.create_task(small_fn(query))
    large_task = asyncio.create_task(large_fn(query))

    # Await the small model first (it should finish sooner)
    small_resp = await small_task
    if gate_fn(small_resp):
        large_task.cancel()             # stop paying for large model
        try:
            await large_task            # let cancellation propagate cleanly
        except asyncio.CancelledError:
            pass
        return small_resp, "small"

    # Small model failed quality gate; wait for large model
    large_resp = await large_task
    return large_resp, "large"

Speculative routing works best when: (a) the cheap model is 3–10× faster than the expensive one, (b) the quality gate can be evaluated quickly (e.g., a short confidence check, not a slow LLM-as-judge), and © the cancellation saves meaningful tokens (longer outputs).

Interview Corner

Q: You are building a cost-optimised LLM API for a customer support product with 100K daily requests. The P95 latency must stay under 2 seconds. Describe the end-to-end cost control architecture you would design.

A: Start with a layered cache stack: exact cache (Redis, SHA-256 key over full request) to handle repeated tickets, plus semantic cache (ANN index, threshold ~0.93) to catch near-duplicates — together these can serve 20–40% of traffic with no model call. Persist a static system prompt and FAQ context at the top of every prompt and enable provider-side prompt caching (Anthropic/OpenAI), saving 60–80% on that portion of input tokens.

For uncached traffic, add a routing classifier: embed the query with a local 22M sentence-transformer, classify into “simple” (FAQ lookup, binary yes/no, short factual) vs. “complex” (multi-turn, policy edge cases, complaints). Route simple queries to a cheap 7B quantised (INT4) model hosted on spot instances, and complex queries to a frontier model. With a ~65% simple-route hit rate and a 10× cost gap between tiers, expected cost drops by ~6×.

For spot/preemptible GPUs: run the cheap tier on spot instances with an on-demand fallback pool; statistically GPU preemptions are rare and requests can retry on the on-demand pool within the 2-second SLA.

Finally, enable continuous batching on your inference server (vLLM or SGLang) to maximise GPU utilisation, target >80% GPU compute utilisation, and set up cost dashboards with per-tier, per-feature-flag breakdowns so you can detect regressions immediately.


Quantised Fallbacks

Running a smaller quantised model is not just about model routing to a different API endpoint — you can also host a quantised version of the same model locally as a fallback that trades quality for cost and latency. The quantisation taxonomy is covered in depth in Quantization I: Post-Training Quantization (GPTQ, AWQ, SmoothQuant) and Quantization II; here we focus on deployment economics.

Memory and throughput impact

A 70B parameter model at FP16 requires approximately 140 GB of GPU memory (2 bytes per parameter). The same model quantised to INT4 (with GPTQ or AWQ) requires around 35 GB — fitting on a single 40 GB A100, versus four A100s for FP16. The cost impact of that memory reduction is dramatic:

Quantisation Memory (70B model) Decode throughput (relative) Quality loss (MMLU)
FP16 (baseline) ~140 GB 1.0× 0%
INT8 (SmoothQuant) ~70 GB 1.3–1.5× < 0.5%
INT4 (AWQ/GPTQ) ~35 GB 1.8–2.2× 1–3%
INT4 + 2-bit outliers (QuIP#) ~25 GB similar to INT4 2–5%

For a fallback tier receiving queries that were already routed away from the frontier model, the 1–3% MMLU degradation is often acceptable.

# Loading a GPTQ-quantised model with vLLM for cost-effective fallback serving
from vllm import LLM, SamplingParams

# vLLM natively supports GPTQ/AWQ via the quantization parameter.
# This model fits on a single A100 (40GB) vs 4x A100 for FP16.
fallback_llm = LLM(
    model="TheBloke/Llama-2-70B-Chat-GPTQ",   # example quantised checkpoint
    quantization="gptq",
    dtype="float16",
    gpu_memory_utilization=0.92,               # leave 8% for KV cache headroom
    max_model_len=4096,
)

fallback_params = SamplingParams(
    temperature=0.7,
    top_p=0.9,
    max_tokens=512,
)

def run_fallback(prompts: list[str]) -> list[str]:
    outputs = fallback_llm.generate(prompts, fallback_params)
    return [o.outputs[0].text for o in outputs]

Producing the quantised checkpoint is its own step, and by 2026 you generally quantise your own weights rather than hunting for a community upload (the once-ubiquitous TheBloke repositories stopped being refreshed in 2024, so they only cover older model generations). The standard open-source paths are llm-compressor (Red Hat / Neural Magic), which emits compressed-tensors checkpoints that vLLM loads with quantization="compressed-tensors" and which covers INT8/INT4 weight-only, W8A8, and FP8 recipes; AutoAWQ and GPTQModel for classic AWQ/GPTQ weights; bitsandbytes for zero-effort NF4 loading in HF Transformers; and llama.cpp’s quantisers for GGUF when the fallback tier is CPU or a laptop. All of them want a small calibration set (typically a few hundred in-domain sequences) — use samples of your production traffic, not generic web text, or you will calibrate the activation scales for the wrong distribution. The capstone walks the whole loop at 100M scale — quantise to int8 then int4, re-measure the benchmark deltas, and export for CPU serving with these same tools as the upgrade path: Evaluation & Serving.

When to invoke a quantised fallback

Quantised fallbacks fit into the routing cascade as the cheap-but-hosted tier, between the semantic cache and the frontier API call. You can also use them for latency degradation gracefully: when the frontier API returns a 429 rate-limit or a timeout, fall back to the local quantised model rather than returning an error to the user.

import httpx

async def call_with_quantised_fallback(prompt: str) -> dict:
    try:
        # Attempt frontier model first
        resp = await call_frontier_api(prompt, timeout=3.0)
        return resp
    except (httpx.TimeoutException, httpx.HTTPStatusError) as e:
        # Fall back to locally hosted quantised model
        text = run_fallback([prompt])[0]
        return {"text": text, "_source": "quantised_fallback"}

Batching for Cost

Batching is the primary lever for maximising GPU utilisation and therefore amortising fixed GPU cost across more tokens. There are two distinct batching strategies relevant to cost control.

Continuous (in-flight) batching

The inference server (vLLM, SGLang, TGI) uses continuous batching (also called iteration-level batching) to keep the GPU full across requests with different lengths. This is covered architecturally in Continuous Batching & Request Scheduling; the cost angle is that higher effective batch size directly reduces cost per token:

\[ \text{cost per token} \approx \frac{\text{GPU-hour price}}{\text{tokens per GPU-hour}} \]

At batch size 1 a modern GPU may generate on the order of 1,000–5,000 tokens/second for a 7B model. At batch size 32 the same GPU generates 10,000–30,000 tokens/second — a 5–6× throughput improvement for the same hardware cost. This is why GPU utilisation is the KPI: each percentage point of utilisation is free capacity.

Offline batching for async workloads

Not all LLM workloads are latency-sensitive. Nightly report generation, bulk document summarisation, evaluation runs, and data labelling jobs can tolerate minutes of latency. For these, the cloud provider’s batch inference API (Anthropic Batches API, OpenAI Batch API) offers a significant discount — on the order of 50% — in exchange for up to 24-hour turnaround.

import anthropic, json, time

batch_client = anthropic.Anthropic()

def run_batch_job(requests: list[dict]) -> list[dict]:
    """
    Submit a batch of requests to Anthropic Batches API.
    Up to 50% cheaper; results available within 24 hours.
    """
    # Build the batch request list
    batch_requests = [
        {
            "custom_id": f"req-{i}",
            "params": {
                "model": "claude-opus-4-5",
                "max_tokens": 1024,
                "messages": req["messages"],
            },
        }
        for i, req in enumerate(requests)
    ]

    # Submit the batch
    batch = batch_client.messages.batches.create(requests=batch_requests)
    print(f"Batch created: {batch.id}, status: {batch.processing_status}")

    # Poll until complete (in production: use a webhook or async poller)
    while batch.processing_status == "in_progress":
        time.sleep(60)
        batch = batch_client.messages.batches.retrieve(batch.id)
        print(f"  Status: {batch.processing_status}, "
              f"succeeded: {batch.request_counts.succeeded}, "
              f"errored: {batch.request_counts.errored}")

    # Collect results
    results = []
    for result in batch_client.messages.batches.results(batch.id):
        if result.result.type == "succeeded":
            results.append({
                "custom_id": result.custom_id,
                "text": result.result.message.content[0].text,
            })
    return results

The batch API is the right choice for any pipeline that is not user-interactive: bulk summarisation, scheduled report generation, embedding generation for new documents, and offline evaluation suites.


Spot and Preemptible GPUs

Cloud GPUs come in two flavours: on-demand (always available, full price) and spot/preemptible (heavily discounted — typically 60–80% cheaper — but can be reclaimed by the cloud provider with 30–120 seconds notice). For a well-designed LLM serving system, spot instances are highly tractable.

Architecture for spot resilience

Load Balancer / API Gateway health-aware routing Spot pool A 2x A10G 70% discount -70% spot discount Spot pool B 2x A10G 70% discount -70% spot discount On-demand pool 1x A10G full price always available always-on floor dashed = reclaimable spot | solid = guaranteed floor
Spot/on-demand GPU fleet for preemption resilience. Traffic fans out from the load balancer to two cheap-but-volatile spot pools (dashed borders, 70% discount) and one always-available on-demand floor (solid border). Stateless workers and SIGTERM-aware draining let the system absorb spot preemptions invisibly within typical SLA windows.

Key design decisions:

  1. Stateless inference workers. Each worker loads the model from shared storage (EFS, GCS) at startup; no local mutable state. Preemption loses nothing.
  2. Short request timeouts + retries. Set a 10-second timeout per inference request. If a spot instance is reclaimed mid-request, the load balancer retries on another instance. For a 2-second P95 SLA, this is usually invisible.
  3. Keep a minimum on-demand floor. Even 1 on-demand instance per deployment ensures some capacity remains during a spot shortage.
  4. Preemption signal handling. Cloud providers send a SIGTERM (GCP) or a metadata flag (AWS) 30–120 seconds before reclamation. The worker should stop accepting new requests and drain in-flight ones.
import signal, sys

class ServiceUnavailableError(Exception):
    """Raised when the worker is draining and cannot accept new requests."""

class InferenceWorker:
    def __init__(self):
        self.accepting_new = True
        self.in_flight = 0
        # Register spot preemption handler
        signal.signal(signal.SIGTERM, self._handle_preemption)

    def _handle_preemption(self, signum, frame):
        """
        Called ~30-120s before the spot instance is reclaimed.
        Stop accepting new requests; let in-flight ones complete.
        """
        print("SIGTERM received — draining inference worker", file=sys.stderr)
        self.accepting_new = False
        # Health check endpoint will start returning 503,
        # causing the load balancer to stop sending new requests.

    async def handle_request(self, request):
        if not self.accepting_new:
            raise ServiceUnavailableError("Worker is draining")
        self.in_flight += 1
        try:
            return await self._run_inference(request)
        finally:
            self.in_flight -= 1

Spot instance cost modelling

Suppose you have a serving system requiring 4 GPU-hours per hour at steady state. On-demand A10G: on the order of USD 1.50/GPU-hour; spot A10G: on the order of USD 0.45/GPU-hour (70% discount). A mixed fleet of 75% spot, 25% on-demand costs:

\[ \text{cost/hour} = 4 \times (0.75 \times 0.45 + 0.25 \times 1.50) = 4 \times (0.3375 + 0.375) = 4 \times 0.7125 = \$2.85 \]

Versus all on-demand at USD 6.00/hour. A 2.1× cost reduction purely from instance type selection, with near-transparent resilience.


Putting It All Together: A Production Cost-Control Stack

The techniques above are not independent — they compose into a layered stack, and the order matters.

Incoming request 1. Exact cache (Redis, SHA-256) ~5 ms hit -> cache hit: return immediately hit miss 2. Semantic cache (ANN, cosine >= 0.93) ~15 ms hit -> cache hit: return immediately hit miss 3. Routing classifier (embed + LR) ~2 ms -> classify: simple / medium / complex [simple] [medium] [complex] Quantised INT4 on spot GPUs cheapest Small API model 7B FP16 API mid tier Frontier model with prompt caching priciest 4. Async batch queue (if SLA permits) Return to client + fill exact/semantic cache fill caches
Layered production cost-control stack where order matters. Each incoming request first hits cheap exact and semantic caches (returning immediately on a hit), then a fast routing classifier dispatches it to the cheapest capable model tier, results optionally pass through an async batch queue, and the final response fills both caches for future reuse. Short-circuiting at the earliest possible layer is the key to 50-90% cost reduction.
import asyncio
from dataclasses import dataclass
from typing import Optional

@dataclass
class CostControlStack:
    exact_cache: "ExactCache"
    semantic_cache: "SemanticCache"
    router: "RoutingClassifier"
    simple_model_fn: callable
    medium_model_fn: callable
    frontier_model_fn: callable

    async def handle(self, request: dict) -> dict:
        messages = request["messages"]
        query = messages[-1]["content"]          # last user turn

        # --- Layer 1: Exact cache ---
        hit = self.exact_cache.get(request)
        if hit:
            return {**hit, "_source": "exact_cache"}

        # --- Layer 2: Semantic cache ---
        hit = self.semantic_cache.get(query)
        if hit:
            return {**hit["response"], "_source": "semantic_cache"}

        # --- Layer 3: Route to cheapest capable model ---
        tier, confidence = self.router.predict(query)

        if tier == "simple":
            resp = await self.simple_model_fn(request)
        elif tier == "medium":
            resp = await self.medium_model_fn(request)
        else:  # "complex"
            resp = await self.frontier_model_fn(request)

        resp["_tier"] = tier
        resp["_router_confidence"] = confidence

        # --- Fill both caches for future requests ---
        self.exact_cache.set(request, resp)
        self.semantic_cache.put(query, resp)

        return resp

Doing this with a real gateway (LiteLLM)

The stack above is written out longhand so that nothing is a black box, but in production most teams put a gateway in front of everything: one OpenAI-compatible endpoint that owns keys, retries, caching, routing, fallbacks, and per-tenant spend limits, so application code never learns which provider answered. LiteLLM is the de-facto open-source choice for that layer as of 2026 (it speaks 100+ providers, and any vLLM/SGLang server you run yourself is just another OpenAI-compatible backend in its model list).

# pip install "litellm[proxy]"
import litellm
from litellm import Router

# Layer 1: exact response cache, keyed on the full request (model + messages +
# params) — the same hashing discipline as our _cache_key, done for us.
litellm.cache = litellm.Cache(type="redis", host="localhost", port="6379")

router = Router(
    model_list=[
        # Cheap tier: our own quantised model served by vLLM on spot GPUs.
        {"model_name": "cheap",
         "litellm_params": {"model": "openai/local-7b-int4",
                            "api_base": "http://vllm-spot:8000/v1",
                            "api_key": "EMPTY"}},
        # Frontier tier: a hosted API.
        {"model_name": "frontier",
         "litellm_params": {"model": "anthropic/claude-opus-4-5"}},
    ],
    fallbacks=[{"cheap": ["frontier"]}],   # 429 / timeout / 5xx on cheap -> frontier
    num_retries=2,
    routing_strategy="latency-based-routing",   # among replicas of one model_name
)

# Our classifier from above still decides the tier; the gateway executes it,
# caches the result, records spend, and handles the failure path we hand-coded.
router_clf = RoutingClassifier(embed_fn, labels=["cheap", "frontier"])
router_clf.fit(train_queries, train_tier_labels)   # from cascade logs

messages = [{"role": "user", "content": query}]
tier, _confidence = router_clf.predict(query)      # "cheap" | "frontier"
resp = router.completion(model=tier, messages=messages, caching=True)

Note the division of labour. The gateway owns the mechanical concerns — caching, retries, fallback on 429/timeout, load-balancing across replicas, per-key budgets — which is exactly the quantised-fallback wrapper and exact cache we wrote by hand above. What it does not own is the judgement: which tier a query deserves, what the quality gate is, and what similarity threshold is safe for your domain. Those stay yours. RouteLLM plugs into the same seam as a trained preference-data router if you would rather not train your own classifier, and LiteLLM’s semantic-cache mode covers Layer 2 when you do not want a separate service.

No cost-control stack is set-and-forget. Wire up the following metrics:

  • Cache hit rate (exact and semantic, separately) — alert if it drops, investigate if query distribution shifted.
  • Tier distribution (fraction of traffic to each model tier) — regressions in routing quality show up as unexpectedly high frontier usage.
  • Cost per request — tagged by product feature, user cohort, model tier. Allows per-feature cost attribution.
  • Quality-gate pass rate — tracks whether the cheap tier is regressing (model update, distribution shift).

See Observability, Logging & LLMOps for the observability infrastructure that powers these dashboards.

Cache invalidation pitfalls

Semantic caches can serve stale content when your knowledge base changes (new policy, product update). Always include a cache-invalidation hook in your content management system: when a document is updated, delete all semantic cache entries whose source documents include that document’s ID. Exact caches keyed on model version should be flushed whenever you update the model.

Practitioner tip: bootstrap your routing classifier cheaply

You do not need labelled data from day one. Deploy the sequential cascade (small-then-large) for the first week, log which tier each request ended up using, and use that as noisy supervision. After 10,000–50,000 examples, train a routing classifier offline and A/B test it against the sequential cascade. Typical result: equal quality, 30–50% lower latency for the majority of traffic.


Key Takeaways

Key Takeaways

  • Cost per request = \(C_\text{input} \cdot n_p + C_\text{output} \cdot n_g\); output tokens cost 3–5× more — reduce generated length before reducing input length.
  • Exact caching (Redis + SHA-256) is zero-latency and zero-risk; it pays off most for embedding calls and templated workloads.
  • Semantic caching adds a 10–20 ms overhead but can serve 20–40% of conversational traffic; calibrate the cosine threshold against your domain’s precision floor.
  • Provider-side prompt caching (Anthropic, OpenAI) can reduce input costs by up to 90% for repeated long prefixes; restructure prompts to put stable content first. On your own weights the same mechanism is free in vLLM (automatic prefix caching) and SGLang (RadixAttention) — it pays out as prefill FLOPs saved, not an invoice line.
  • Model routing cascades (sequential or classifier-based) deliver 2–6× cost reductions by routing simple queries to cheap models; bootstrap the classifier from cascade logs, and let a gateway (LiteLLM) own the mechanics — caching, retries, fallbacks, per-tenant budgets — while you keep the judgement calls (tier policy, quality gates, thresholds).
  • Speculative routing fires cheap and expensive models in parallel and cancels the expensive one on early success — optimal when cheap model latency << expensive model latency and cancellation is cheap.
  • INT4 quantisation halves GPU memory relative to INT8 and reduces hardware cost 4× relative to FP16, with 1–3% quality loss on standard benchmarks — acceptable for a fallback tier.
  • Spot/preemptible GPUs provide 60–80% cost reduction; make workers stateless, handle SIGTERM gracefully, and keep a small on-demand floor.
  • Monitor cache hit rate, tier distribution, and cost per request continuously; routing quality degrades silently with distribution shift.

State of the Art & Resources (2026)

LLM cost control has matured into a well-structured engineering discipline: semantic caching, model-routing cascades, and provider-side KV-prefix caching can collectively cut production API spend by 50–90% without sacrificing quality. By 2026 these patterns are largely table stakes rather than bespoke engineering — bundled into unified LLM gateways (e.g., LiteLLM) alongside purpose-built routing frameworks such as RouteLLM, and SGLang’s RadixAttention has become a mainstream serving-layer default for KV-prefix reuse rather than a research novelty.

Foundational work

Recent advances (2023–2026)

Open-source & tools

  • BerriAI/litellm — unified gateway/SDK for 100+ LLM providers with built-in request caching and router-based fallback/load-balancing across model deployments; the most common way production teams wire caching and routing together as of 2026.
  • zilliztech/GPTCache — pluggable semantic cache library for LLM APIs; supports FAISS, Qdrant, and Milvus backends with drop-in LangChain/LlamaIndex integration. Feature development has slowed since 2024 (maintainers note they are no longer adding support for new model APIs) — still a solid reference implementation of the pattern, but prefer LiteLLM above for an actively-developed dependency.
  • lm-sys/RouteLLM — open-source routing framework from LMSYS; drop-in OpenAI-compatible client that redirects queries to cheap or strong models based on trained preference-data routers.
  • vllm-project/llm-compressor — the maintained path for producing your own quantised fallback checkpoints (INT8/INT4 weight-only, W8A8, FP8) in the compressed-tensors format that vLLM loads natively; supersedes hunting for community-uploaded GPTQ/AWQ repos.
  • sgl-project/sglang — high-performance serving framework with RadixAttention prefix caching; achieves up to 6.4× higher throughput than baseline systems (verified against the original paper’s abstract), and by 2026 supports current-generation accelerators (e.g., NVIDIA GB300 NVL72) alongside its original GPU targets.

Go deeper

Further Reading

  • Kang et al., “LLM-Blender: Ensembling Large Language Models with Pairwise Ranking and Generative Fusion,” ACL 2023 — foundational work on ensembling and routing across LLMs.
  • Chen et al., “FrugalGPT: How to Use Large Language Models While Reducing Cost and Improving Performance,” 2023 — introduces the LLM cascade framework and cost-quality trade-off analysis.
  • Vllm project (Kwon et al., “Efficient Memory Management for Large Language Model Serving with PagedAttention,” SOSP 2023) — the continuous batching and KV-cache management paper underlying most open-source serving stacks.
  • Dao et al., “FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning,” ICLR 2024 — understanding IO-efficient attention is prerequisite to understanding why KV-cache reuse saves so much.
  • Lin et al., “AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration,” MLSys 2024 — the quantisation method most commonly used in quantised fallback deployments.
  • SGLang RadixAttention (Zheng et al., “SGLang: Efficient Execution of Structured Language Model Programs,” 2024) — prefix cache reuse at the serving-system level, complementing provider-side caching.
  • GPTCache (GitHub: zilliztech/GPTCache) — open-source semantic cache library with pluggable vector stores and embedding backends, useful as a reference implementation.

Exercises

1. (Conceptual) The _cache_key function in the Exact Caching section hashes a JSON payload containing model, messages, and all sampling params. Suppose an engineer “simplifies” it to key only on the last user message string. Describe two distinct failure modes this introduces, and explain why the chapter insists the model version (not just the name) belongs in the key.

Solution

Keying only on the last user message drops every other input that changes the model’s output, so the cache returns answers that were computed under different conditions.

Two distinct failure modes:

  • Cross-tenant / cross-system-prompt poisoning. The chapter warns that the system prompt “varies per tenant.” Two tenants can send the identical user message "What is my account balance?" but with different injected system prompts (different tenant context, different tools, different persona). Keying only on the user message makes tenant B receive tenant A’s cached answer — a correctness and data-leak bug.
  • Sampling-parameter mismatch. Two requests with the same user message but different temperature, max_tokens, or top_p are semantically different requests. A request that asked for a 2000-token essay would get served a cached 50-token reply produced under max_tokens=50, and a temperature=0 deterministic call could be served a high-temperature creative sample. The chapter explicitly lists sampling hyperparameters as part of “everything that would change the model’s output.”

The model version matters because model behaviour changes across releases even when the public name is stable. If you key on "claude-opus" rather than a pinned version/commit, then after a silent model upgrade the cache will keep serving answers generated by the old weights indefinitely (bounded only by the TTL). Keying on the version guarantees a natural, automatic cache invalidation on every model update — which is exactly the flush behaviour the “Cache invalidation pitfalls” admonition prescribes.

2. (Quantitative) You run a documentation assistant with a fixed 3,000-token system prompt. Prices are $0.003/1K input, $0.0003/1K cached input, and traffic is 15,000 calls/day. With provider-side prompt caching the system prompt is a cache hit on 90% of calls and a full-price miss (first call of a fresh prefix) on the remaining 10%. Ignoring the cache-write premium (as the chapter’s worked example does) and considering only the system-prompt portion of the bill, compute the daily cost with and without prompt caching, and the annual saving.

Solution

Cost of the system prompt on a full-price call:

\[ \frac{3000 \times 0.003}{1000} = \$0.009 \text{ per call} \]

Cost of the system prompt on a cache-hit call:

\[ \frac{3000 \times 0.0003}{1000} = \$0.0009 \text{ per call} \]

Without caching, every call pays full price:

\[ 15{,}000 \times 0.009 = \$135.00/\text{day} \]

With caching, 10% pay full price and 90% pay the cached rate:

\[ 15{,}000 \times \left(0.10 \times 0.009 + 0.90 \times 0.0009\right) = 15{,}000 \times (0.0009 + 0.00081) = 15{,}000 \times 0.00171 = \$25.65/\text{day} \]

Daily saving: \(135.00 - 25.65 = \$109.35/\text{day}\).

Annual saving: \(109.35 \times 365 \approx \$39{,}913/\text{year}\) — from restructuring nothing but the system-prompt handling, consistent with the chapter’s claim that stable prefixes yield up-to-90% input savings (here the hit-path input cost drops by exactly 90%).

3. (Quantitative) Using the sequential-cascade cost model from the chapter, \(\mathbb{E}[\text{cost}] = c_s + (1 - p_s)\,c_l\), take a cheap tier at \(c_s = \$0.001\) and a frontier tier at \(c_l = \$0.015\). (a) Compute the expected cost and the cost-reduction factor versus always calling the frontier model when the cheap tier handles \(p_s = 0.60\) of traffic. (b) Derive the break-even hit rate \(p_s^\star\) below which the sequential cascade is more expensive than just calling the frontier model directly. Comment on what makes the break-even so low.

Solution

(a) At \(p_s = 0.60\):

\[ \mathbb{E}[\text{cost}] = 0.001 + (1 - 0.60)\times 0.015 = 0.001 + 0.40 \times 0.015 = 0.001 + 0.006 = \$0.007 \]

Always-frontier costs \(c_l = \$0.015\) per query, so the reduction factor is

\[ \frac{0.015}{0.007} \approx 2.14\times. \]

(b) The cascade is cheaper than always-frontier when \(\mathbb{E}[\text{cost}] < c_l\):

\[ c_s + (1 - p_s)\,c_l < c_l \;\Longleftrightarrow\; c_s < p_s\,c_l \;\Longleftrightarrow\; p_s > \frac{c_s}{c_l}. \]

So the break-even hit rate is

\[ p_s^\star = \frac{c_s}{c_l} = \frac{0.001}{0.015} \approx 0.067 \;(6.7\%). \]

The break-even is this low because in a sequential cascade the only wasted spend on an escalated query is the cheap-tier call \(c_s\), and \(c_s\) is tiny relative to \(c_l\) (a 15x gap here). You only need the cheap tier to fully resolve about 1 in 15 queries to pay for the redundant cheap call on the other 14. Practically, any cheap model that resolves even a small minority of traffic is worth adding — the risk in a cascade is not the cost math but the quality gate wrongly accepting bad cheap-tier answers.

4. (Quantitative) A serving system needs 8 GPU-hours per hour at steady state. On-demand A10G costs $1.20/GPU-hour; spot A10G costs $0.36/GPU-hour. (a) Compute the hourly cost of an 80%-spot / 20%-on-demand fleet and the reduction versus all on-demand. (b) Your finance team caps GPU spend at $5.00/hour. What is the minimum fraction of the fleet that must run on spot to stay within budget? Express the answer as a fraction and comment on whether it still leaves room for an on-demand floor.

Solution

(a) With spot fraction \(f = 0.80\), the blended per-GPU-hour price is

\[ 0.80 \times 0.36 + 0.20 \times 1.20 = 0.288 + 0.240 = \$0.528/\text{GPU-hour}. \]

Over 8 GPU-hours/hour:

\[ 8 \times 0.528 = \$4.224/\text{hour}. \]

All on-demand costs \(8 \times 1.20 = \$9.60/\text{hour}\), so the reduction is \(9.60 / 4.224 \approx 2.27\times\).

(b) Let \(f\) be the spot fraction. Require the hourly cost to satisfy

\[ 8 \times \big(f \times 0.36 + (1 - f)\times 1.20\big) \le 5.00. \]

Divide by 8:

\[ 0.36 f + 1.20 - 1.20 f \le 0.625 \;\Longrightarrow\; 1.20 - 0.84 f \le 0.625 \;\Longrightarrow\; 0.84 f \ge 0.575 \;\Longrightarrow\; f \ge 0.6845. \]

So at least about 68.5% spot is required. Since the budget only forces roughly 68.5% spot, you can keep the remaining ~31.5% on-demand — comfortably more than the “minimum on-demand floor” the chapter recommends for surviving a spot shortage. The budget is compatible with a resilient mixed fleet.

5. (Implementation) The chapter’s “Handling non-determinism” note argues you should not cache high-temperature responses forever, and suggests caching only when temperature == 0, or otherwise using a short TTL. Modify exact_cache_set (and add a small guard to exact_cache_get if needed) so that: requests with temperature == 0 are cached with the default 30-day TTL, requests with temperature > 0 are cached with a short 2-hour TTL, and the caller can opt out entirely by passing cache_nonzero_temp=False (in which case non-deterministic requests are never written). Keep the chapter’s Redis/SHA-256 style.

Solution

The key insight is that the TTL, not the key, encodes the freshness policy — the key already covers temperature via params, so a temperature=0 request and a temperature=0.7 request never collide. We branch on the temperature only to choose the TTL and to decide whether to write at all.

import hashlib, json, redis

client = redis.Redis(host="localhost", port=6379, decode_responses=False)

DETERMINISTIC_TTL = 86400 * 30   # 30 days
NONDETERMINISTIC_TTL = 3600 * 2  # 2 hours

def _cache_key(model: str, messages: list[dict], params: dict) -> str:
    payload = json.dumps(
        {"model": model, "messages": messages, **params},
        sort_keys=True, ensure_ascii=False,
    ).encode("utf-8")
    return "llm:exact:" + hashlib.sha256(payload).hexdigest()

def exact_cache_set(model, messages, params, response,
                    cache_nonzero_temp: bool = True) -> bool:
    """
    Write the response with a freshness-appropriate TTL.
    Returns True if the entry was written, False if skipped.
    """
    temperature = params.get("temperature", 0.0)
    if temperature == 0:
        ttl = DETERMINISTIC_TTL          # safe to cache long: reproducible
    else:
        if not cache_nonzero_temp:
            return False                 # caller opted out of caching sampling
        ttl = NONDETERMINISTIC_TTL       # burst capture only, expires quickly
    key = _cache_key(model, messages, params)
    client.setex(key, ttl, json.dumps(response))
    return True

def exact_cache_get(model, messages, params):
    # No change needed: a stale non-deterministic entry simply expires via
    # its short TTL, so a miss after 2 hours is automatic. The key already
    # includes temperature, so temperature==0 and temperature>0 never mix.
    key = _cache_key(model, messages, params)
    blob = client.get(key)
    return json.loads(blob) if blob is not None else None

Notes: because temperature is part of the hashed key, no explicit guard is needed in exact_cache_get to keep deterministic and non-deterministic entries separate — the short TTL alone bounds how long a sampled response can be replayed. Setting cache_nonzero_temp=False gives the strictest policy: only reproducible (temperature == 0) calls are ever served from cache, exactly the “cache only when temperature == 0” compromise the chapter describes.

6. (Implementation) The chapter’s SemanticCache does a Python-level linear scan and stores entries forever. For a cache of \(N\) entries, that is \(O(N)\) Python-object cosine calls per lookup and unbounded memory growth. Rewrite get/put to (a) hold all embeddings in a single contiguous NumPy matrix and score every entry with one vectorised matrix-vector product, and (b) support TTL-based expiry so stale entries (e.g., after a knowledge-base update) drop out. Preserve the unit-norm cosine assumption and the threshold semantics.

Solution

Stacking embeddings into one (N, d) matrix turns the scan into a single BLAS-backed matrix @ vector, which is far faster than \(N\) separate np.dot calls even though the asymptotic work is still \(O(Nd)\). TTL expiry is handled by storing an insertion timestamp per row and masking out (and lazily compacting) expired rows.

import time
import numpy as np
from typing import Optional

class SemanticCache:
    """
    Vectorised semantic cache with TTL expiry.
    Embeddings are assumed unit-normed, so cosine == dot product.
    """
    def __init__(self, embed_fn, threshold: float = 0.93,
                 ttl_seconds: float = 86400.0, dim: Optional[int] = None):
        self.embed_fn = embed_fn
        self.threshold = threshold
        self.ttl = ttl_seconds
        self._mat = None if dim is None else np.empty((0, dim), dtype=np.float32)
        self._entries: list[dict] = []      # parallel to rows of _mat
        self._timestamps: list[float] = []  # insertion time per row

    def _expire(self) -> None:
        """Drop rows older than the TTL (lazy compaction)."""
        if not self._entries:
            return
        now = time.time()
        ts = np.asarray(self._timestamps)
        keep = ts >= (now - self.ttl)
        if keep.all():
            return
        self._mat = self._mat[keep]
        self._entries = [e for e, k in zip(self._entries, keep) if k]
        self._timestamps = [t for t, k in zip(self._timestamps, keep) if k]

    def get(self, query: str) -> Optional[dict]:
        self._expire()
        if self._mat is None or self._mat.shape[0] == 0:
            return None
        q = self.embed_fn(query).astype(np.float32)   # unit-normed
        scores = self._mat @ q                          # one mat-vec, (N,)
        idx = int(np.argmax(scores))
        if scores[idx] >= self.threshold:
            return self._entries[idx]                   # cache hit
        return None

    def put(self, query: str, response: dict) -> None:
        emb = self.embed_fn(query).astype(np.float32).reshape(1, -1)
        if self._mat is None:
            self._mat = np.empty((0, emb.shape[1]), dtype=np.float32)
        self._mat = np.vstack([self._mat, emb])
        self._entries.append({"query": query, "response": response})
        self._timestamps.append(time.time())

This preserves the original semantics: unit-norm inputs make self._mat @ q a vector of cosine similarities, and the same >= threshold admission rule applies. The two changes are (1) one vectorised matrix-vector product replaces the Python generator-and-max scan, and (2) _expire() enforces the TTL so a knowledge-base update no longer serves indefinitely-stale answers — the same invalidation concern raised in the “Cache invalidation pitfalls” admonition, here handled by time rather than by explicit document-ID deletion. For production scale beyond a few thousand entries, the chapter’s advice still holds: replace this matrix with a FAISS/Qdrant ANN index.