Skip to main content
All posts

LLM Inference Cost Optimization 2026: A Three-Layer Caching Strategy

Stack prompt caching, semantic caching, and vLLM KV prefix caching into three layers to cut LLM inference cost by 40-90% — a 2026 production architecture with working code.

IntentCode··16 min read

Why "inference cost" is now a survival variable for LLM products

If you have shipped an LLM feature to production, the first invoice probably gave you pause. Token cost is invisible in a demo, but the moment traffic arrives, inference becomes the single largest line in your infra budget. The 2026 reality is clear: producing the same result more cheaply matters more to your P&L than making the model smarter.

The key insight is that most requests are more similar than you think. In a support bot, internal search, or RAG Q&A, a handful of top questions account for over half the traffic, and the system prompt and document context are nearly identical across requests. Yet without caching, every request runs full inference — repeating the same computation thousands of times.

This article lays out a production architecture that stacks three layers — provider prompt (prefix) caching, semantic caching, and vLLM's KV prefix cache — to cut inference cost by 40-90%, with code. Because each layer catches a different kind of redundancy, stacking them beats using any one alone.

Background: what each of the three layers catches

Not all caching is the same. The three layers differ in which kind of redundancy they capture.

LayerMatch basisRedundancy caughtSavingsCost on hit
Semantic cacheEmbedding similarity (meaning)Same question, different wording30-70%Zero model call (~100%)
Prompt (prefix) cacheExact token-prefix matchIdentical system prompt / docs50-90%Cache-read rate only
KV cache (self-host)Token prefix + GPU KVMulti-turn chat, long-doc requeryHigher throughput, lower latencySkips GPU recompute

The semantic cache recognizes "How do I get a refund?" and "tell me the refund process" as the same question and skips the model entirely. The prompt cache hits only when the token prefix is byte-for-byte identical — not semantically — but it cheaply handles the shared parts like system prompts and long documents. The KV cache reuses GPU-side KV tensors in a self-hosted engine (e.g. vLLM) to skip recomputing a shared prefix.

In the request path, traffic flows semantic cache → prefix cache → full inference; the higher it is stopped, the cheaper it is.

code
request ──▶ [1] semantic cache ── hit ──▶ return stored answer (0 model calls)
              │ miss
              ▼
            [2] prefix / prompt cache ── prefix hit ──▶ served at cache-read rate
              │
              ▼
            [3] full inference (vLLM KV cache absorbs some GPU recompute)

Deep dive: implementing each layer

Layer 1 — Semantic cache (meaning-based, avoids the model)

The biggest savings come from the semantic cache, which avoids calling the model at all. Embed the question, compare cosine similarity against existing cache entries, and return the stored answer if it clears the threshold. Below is a minimal implementation backed by Redis vector search.

python
# semantic_cache.py — embedding-similarity response cache
import numpy as np
from redis import Redis
from redis.commands.search.query import Query

# ⚠️ Note: the THRESHOLD governs cache quality.
# Too low → distinct questions treated as one (false hit) → wrong answer
SIM_THRESHOLD = 0.83          # cosine similarity start point (0.75-0.85 recommended)
TTL_SECONDS = 60 * 60 * 24    # match TTL to how often data changes

def lookup(redis: Redis, embed: np.ndarray) -> str | None:
    """Find the most similar entry; return its answer if above threshold."""
    q = (
        Query("*=>[KNN 1 @vec $v AS score]")
        .sort_by("score")
        .return_fields("answer", "score")
        .dialect(2)
    )
    res = redis.ft("idx:cache").search(q, {"v": embed.astype(np.float32).tobytes()})
    if not res.docs:
        return None
    doc = res.docs[0]
    cosine = 1 - float(doc.score)          # redis returns distance → convert to similarity
    return doc.answer if cosine >= SIM_THRESHOLD else None

def store(redis: Redis, key: str, embed: np.ndarray, answer: str) -> None:
    """On a miss, store the (question embedding, answer) pair with a TTL."""
    redis.hset(f"cache:{key}", mapping={"vec": embed.astype(np.float32).tobytes(),
                                        "answer": answer})
    redis.expire(f"cache:{key}", TTL_SECONDS)

The threshold is not a set-and-forget value. Monitor false-hit rate and hit rate in production and tune it. For a managed quick start, Redis LangCache handles embedding, storage, and similarity search via an API, with a default LANGCACHE_CACHE_THRESHOLD=0.65 you raise or lower to fit your FAQ style.

Layer 2 — Prompt (prefix) cache (provider API)

Commercial APIs (e.g. Anthropic) cache token prefixes shared across requests, such as system prompts and documents. With Anthropic, cache reads cost 10% of the base rate (a 90% discount) while cache writes carry a 25% premium over base input price — so you break even once an identical prefix is reused 2+ times. Put the invariant parts (system instructions, long documents) up front and mark the cache point with cache_control.

python
# prompt_cache.py — Anthropic prompt caching (reuse a long-doc prefix)
import anthropic

client = anthropic.Anthropic()

resp = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=1024,
    system=[
        {"type": "text", "text": "You are IntentCode's internal-docs assistant."},
        {
            # ⚠️ Cache point: this block (long manual) is identical per request → cache-read rate
            "type": "text",
            "text": LONG_MANUAL_TEXT,           # a fixed document of tens of thousands of tokens
            "cache_control": {"type": "ephemeral"},
        },
    ],
    messages=[{"role": "user", "content": "Summarize the refund policy in 3 lines"}],
)
# Track savings via usage.cache_read_input_tokens
print(resp.usage)

Track cost via usage.cache_read_input_tokens in the response — the larger it is, the better your prefix design.

Layer 3 — vLLM KV prefix cache (self-host)

If you serve the model on your own GPUs, vLLM's Automatic Prefix Caching (APC) reuses the KV cache. A new request sharing a prefix skips recomputing the shared part, raising throughput and cutting latency on long-document requeries and multi-turn chat. Enabling it is one engine flag.

bash
# Start the vLLM server — enable Automatic Prefix Caching
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --enable-prefix-caching \
  --max-model-len 8192 \
  --gpu-memory-utilization 0.90
python
# Or enable it directly from the Python engine
from vllm import LLM

llm = LLM(
    model="meta-llama/Llama-3.1-8B-Instruct",
    enable_prefix_caching=True,   # ⚠️ KV reuse — pays off for shared system prompts/docs
    gpu_memory_utilization=0.90,
)

Practical guide: wiring three layers into one request path

Chain the three layers into a single request path. Stop and return early at the higher layers; only requests that pass flow downward.

python
# pipeline.py — semantic → prefix → full inference, in order
async def answer(question: str) -> str:
    embed = await embed_query(question)               # 1) embed the question

    cached = lookup(redis, embed)                     # 2) semantic cache
    if cached:
        metrics.inc("cache.semantic.hit")
        return cached                                  #    zero model calls

    # 3) LLM call with prompt caching on (long-doc prefix at cache-read rate)
    answer = await call_llm_with_prefix_cache(question)

    store(redis, key=hash_q(question), embed=embed, answer=answer)  # 4) populate cache
    metrics.inc("cache.semantic.miss")
    return answer

An operational checklist: first, start the semantic threshold near 0.83 and tune by watching false hits. Second, match TTL to data change frequency (short for fast-changing prices/inventory). Third, place fixed prefixes (system instructions, docs) up front and variable parts (user question) at the end to raise prefix hit rate. Fourth, keep hit rate, savings, and false hits on a live dashboard.

Troubleshooting: three common field issues

① The semantic cache returns wrong answers (false hits). The threshold is too low or the embedding model is weak, mapping semantically distinct questions to nearby vectors. Raise the threshold (e.g. 0.83 → 0.88) and swap in a stronger embedding model. Cache quality depends on embedding-model choice more than any other knob.

② Prompt caching is on but cost did not drop. Variable data (timestamps, usernames) is mixed into the front of the prefix, so the prefix differs per request. Move fixed blocks to the very front and push variable values to the back. Also, if an identical prefix is reused fewer than twice, the write premium makes it a net loss — exclude low-reuse prefixes from caching.

③ GPTCache slows down under concurrent writes. GPTCache defaults to a SQLite backend that scales poorly under concurrent write load. In production, switch the backend to Redis or PostgreSQL. A single-node SQLite setup taking live traffic is a classic bottleneck.

Conclusion + the IntentCode view

LLM inference cost is not a "swap the model" problem but a design problem of "not repeating the same computation." Strip out meaning-level redundancy with the semantic cache, prefix redundancy with prompt caching, and GPU recompute with the KV cache, and you can cut cost by 40-90% without hurting quality.

In our MLOps and RAG lines, IntentCode applies this three-layer caching as a default architecture, so a client's LLM service gets cheaper per unit as traffic grows. Translating intent into cost-efficient code is what we do.

Get new posts by email

Notes from building our products, about twice a week. Questions? Ask Dot.