Caching, Latency & Cost

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written on 2026-09-30 against langchain-core 1.6.6 and redis-py on Python 3.12, Spring AI 2.0.1 with Micrometer on Spring Boot 4.1.1, and LangChain4j 1.20.2 (see the version baseline). The semantic cache, the invalidation and the cost function were run against an in-memory vector store, fakeredis and fake embeddings; the Java classes were compiled against the released jars and were not run.

The latency budget and total cost of ownership showed where time and money go: the generator’s prefill of a long retrieved context, the output tokens, and the fixed cost of the stores. Caching removes repeated work; prompt caching makes the unavoidable work cheaper; cascades send easy questions to cheap models; accounting makes all of it measurable. The SQL side of a semantic cache in pgvector is on Agent Memory & Semantic Cache, and Redis as a cache in Redis Use Cases and Patterns and Spring Boot: Redis as a Cache.

Cache tiers

Tier Key → value Saves Invalidate when

Response (exact)

hash(normalised question, filters, ACL set, prompt and model version) → answer with citations

Everything: retrieval, rerank, generation.

Any cited chunk changes; prompt or model version changes; TTL.

Semantic (approximate)

embedding of the question, filtered by ACL set → answer

Everything, for paraphrases.

Same as response; plus threshold tuning.

Retrieval

hash(standalone query, filter) → ranked chunk IDs

Vector search and rerank; generation still runs (good with conversation context).

Any chunk in the index for the filter changes (usually: short TTL).

Embedding

hash(model, text) → vector

Query embeddings for repeated queries; re-embedding unchanged chunks at ingestion.

Embedding model change.

Chunk

chunk ID → text and metadata

Store round trips for hot chunks.

Chunk update or delete.

The key of every tier that returns content must include the caller’s ACL set (the sorted readable ACL tags, or a hash of them): an answer computed for a security-team member must never be served to someone else. It must also include the model and prompt versions, so that a rollout does not serve answers from the previous configuration.

The semantic cache and its threshold

A semantic cache returns a stored answer when a new question is close enough to a stored one:

where is the set of cached questions with the same ACL set. The threshold trades hit rate against false hits: "How do I enable TLS for Kafka?" and "How do I disable TLS for Kafka?" are very close in most embedding spaces. Tune on labelled pairs of questions that should and should not share an answer, and measure the false-hit rate by judging a sample of hits offline. Start strict (high ); a cache that is rarely used is cheaper than one that is wrong. Semantic caching is a poor fit for conversational follow-ups, whose meaning depends on history: key it on the standalone condensed question, or skip it when there is history.

The cache below stores questions in a vector store collection of their own (pgvector in production, the in-memory store in tests) and keeps a reverse index in Redis from chunk ID to cache entries, for invalidation:

docsassistant/caching/semantic_cache.py — ACL-scoped semantic cache with a reverse index (Python, langchain-core 1.6.6, redis-py)
import hashlib
import json
import uuid
from collections.abc import Callable
from typing import Any

import redis
from langchain_core.documents import Document
from langchain_core.vectorstores import VectorStore


def acl_key(readable_acls: list[str], config_version: str) -> str:
    return hashlib.sha256(("|".join(sorted(readable_acls)) + "#" + config_version).encode()).hexdigest()[:16]


def pgvector_filter(acl: str) -> dict:
    return {"acl_key": {"$eq": acl}}


class SemanticAnswerCache:
    def __init__(self, store: VectorStore, index: redis.Redis, threshold: float = 0.93,
                 acl_filter: Callable[[str], Any] = pgvector_filter):
        self.store, self.index, self.threshold, self.acl_filter = store, index, threshold, acl_filter

    def lookup(self, question: str, acl: str) -> dict | None:
        hits = self.store.similarity_search_with_relevance_scores(question, k=1, filter=self.acl_filter(acl))
        if hits and hits[0][1] >= self.threshold:
            return json.loads(hits[0][0].metadata["payload"])
        return None

    def put(self, question: str, acl: str, answer: dict, cited_chunk_ids: list[str]) -> str:
        entry_id = str(uuid.uuid4())
        self.store.add_documents([Document(page_content=question, metadata={
            "acl_key": acl, "payload": json.dumps(answer)})], ids=[entry_id])
        for chunk_id in cited_chunk_ids:
            self.index.sadd(f"cites:{chunk_id}", entry_id)             # reverse index for invalidation
        return entry_id

    def invalidate_chunk(self, chunk_id: str) -> int:
        entries = list(self.index.smembers(f"cites:{chunk_id}"))
        if entries:
            self.store.delete(ids=entries)
        self.index.delete(f"cites:{chunk_id}")
        return len(entries)

similarity_search_with_relevance_scores normalises scores to with the store’s relevance function (for cosine distance, ), so the threshold means the same across stores. The filter syntax is the store’s own, so it is injected: the default builds a PGVector filter, and the tests pass a function for the in-memory store, whose filter is a predicate over documents. InMemoryVectorStore does not define a relevance function (its scores are already cosine similarities), so the tests subclass it with an identity _select_relevance_score_fn. Spring AI users get a ready-made semantic cache from the Redis vector store module; see Spring Boot: Redis as a Vector Database and Agent Memory & Semantic Cache.

Event-driven invalidation

Time-to-live alone is the wrong invalidation strategy for a documentation assistant: a TTL short enough to be safe throws away most of the cache, and a long one serves answers citing deleted pages. Invalidate on change events instead, from the same stream that drives re-indexing (CDC with Debezium and Kafka):

  1. The re-index worker knows which chunk IDs of a document changed or were deleted.

  2. It publishes one invalidation event per chunk ID — on a Kafka topic (docs.cache-invalidation) when several services hold caches and must not miss events, or on Redis Pub/Sub when caches are local to a few replicas and a missed message is acceptable (Pub/Sub is fire-and-forget; Redis Streams add persistence).

  3. Every cache owner removes the entries that cited those chunks through the reverse index, and drops retrieval-cache entries for the affected filter scope.

  4. A model or prompt rollout bumps the config_version in the cache key, which invalidates everything logically without touching storage; old entries expire by TTL.

Kafka’s delivery semantics and Redis Pub/Sub are covered in Delivery Guarantees and Redis Streams and Pub/Sub.

Prompt caching for long contexts

Providers can cache the prefix of a prompt: when a request starts with exactly the same tokens as a recent one, the cached key-value state is reused, input tokens in the cached part are billed at a fraction of the normal price, and time to first token drops because the prefix is not prefilled again. Some providers cache automatically above a minimum prefix length; others need an explicit cache marker on the message or content block. Self-hosted engines such as vLLM do the same with automatic prefix caching (see Inference Optimisation).

RAG prompts can be arranged to benefit: put the stable parts first (system prompt, tool definitions, citation instructions, few-shot examples), then the long but shared parts (a whole document for contextual retrieval, a frequently used reference page), and the variable parts (retrieved chunks, history, question) last. With a cached prefix of tokens out of , billed at a fraction of the input price :

For the per-chunk contextual-retrieval calls of Advanced Indexing Patterns, where every call repeats the same whole document, prompt caching is what makes the technique affordable. Spring AI reports cache usage portably in Usage.getCacheReadInputTokens() and getCacheWriteInputTokens(); LangChain reports it in usage_metadata["input_token_details"] (cache_read, cache_creation) where the provider supplies it.

Model cascades

Most documentation questions are easy. A cascade answers with a small, cheap model first and escalates to the large model only when a check fails: the grounding check of Hallucination Detection & Correction, a low retrieval score, a route that needs reasoning, or the small model abstaining. With the share of requests the small model handles and , the per-request costs, the expected cost is

which beats always using the large model when . The catch is latency: an escalated request pays for both models. Escalate before streaming starts (on retrieval signals or a routing decision) when latency matters, and after (on a failed check) when cost matters more. Gateways such as LiteLLM can implement cascades and fallbacks centrally; see LLM Gateways.

Token and cost accounting per request

Every request should know its own cost, for budgets and rate limits (Serving RAG as an API), for the usage field of the response, and for dashboards. All three frameworks expose token usage on the model response:

docsassistant/caching/cost.py — cost from usage metadata (Python, langchain-core 1.6.6)
from dataclasses import dataclass

from langchain_core.messages import AIMessage


@dataclass(frozen=True)
class Price:
    input_per_mtok: float          # USD per million input tokens
    output_per_mtok: float
    cached_input_per_mtok: float   # USD per million cache-read input tokens


def request_cost(messages: list[AIMessage], price: Price) -> dict:
    """Sum usage over every model call of a request (an agent makes several) and price it."""
    inp = out = cached = 0
    for m in messages:
        usage = m.usage_metadata or {}
        inp += usage.get("input_tokens", 0)
        out += usage.get("output_tokens", 0)
        cached += (usage.get("input_token_details") or {}).get("cache_read", 0)
    cost = ((inp - cached) * price.input_per_mtok + cached * price.cached_input_per_mtok
            + out * price.output_per_mtok) / 1_000_000
    return {"inputTokens": inp, "outputTokens": out, "cachedInputTokens": cached, "costUsd": round(cost, 6)}

Source: LangChain — Messages (usage_metadata).

In Spring AI a small advisor reads Usage from every ChatResponse and records it with Micrometer, tagged by model; Spring AI’s own observations already publish gen_ai.client.token.usage (see Spring AI: Observability), so the advisor adds only what they do not: the price:

CostAccountingAdvisor.java — priced token usage per call (Java, Spring AI 2.0.1)
package com.example.docsassistant.rag.cost;

import java.util.Map;

import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.core.Ordered;

public class CostAccountingAdvisor implements CallAdvisor {

    public record Price(double inputPerMtok, double outputPerMtok, double cachedInputPerMtok) {
    }

    private final MeterRegistry meters;
    private final Map<String, Price> prices;       // by model name, from configuration

    public CostAccountingAdvisor(MeterRegistry meters, Map<String, Price> prices) {
        this.meters = meters;
        this.prices = prices;
    }

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
        ChatClientResponse response = chain.nextCall(request);
        if (response.chatResponse() == null) {
            return response;
        }
        var metadata = response.chatResponse().getMetadata();
        Usage usage = metadata.getUsage();
        Price price = prices.get(metadata.getModel());
        if (usage != null && price != null) {
            long input = usage.getPromptTokens() == null ? 0 : usage.getPromptTokens();
            long output = usage.getCompletionTokens() == null ? 0 : usage.getCompletionTokens();
            long cached = usage.getCacheReadInputTokens() == null ? 0 : usage.getCacheReadInputTokens();
            double usd = ((input - cached) * price.inputPerMtok() + cached * price.cachedInputPerMtok()
                    + output * price.outputPerMtok()) / 1_000_000.0;
            meters.counter("docsassistant.llm.cost.usd", "model", metadata.getModel()).increment(usd);
            response.context().put("costUsd", usd);
        }
        return response;
    }

    @Override
    public String getName() {
        return "costAccounting";
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE - 100;
    }
}

In LangChain4j, a ChatModelListener registered on the model sees every response with its TokenUsage:

CostListener.java — priced token usage per call (Java, LangChain4j 1.20.2)
package com.example.docsassistant.l4j.cost;

import java.util.concurrent.atomic.DoubleAdder;

import dev.langchain4j.model.chat.listener.ChatModelListener;
import dev.langchain4j.model.chat.listener.ChatModelResponseContext;
import dev.langchain4j.model.output.TokenUsage;

public class CostListener implements ChatModelListener {

    private final double inputPerMtok;
    private final double outputPerMtok;
    private final DoubleAdder totalUsd = new DoubleAdder();

    public CostListener(double inputPerMtok, double outputPerMtok) {
        this.inputPerMtok = inputPerMtok;
        this.outputPerMtok = outputPerMtok;
    }

    @Override
    public void onResponse(ChatModelResponseContext context) {
        TokenUsage usage = context.chatResponse().tokenUsage();
        if (usage == null) {
            return;
        }
        int in = usage.inputTokenCount() == null ? 0 : usage.inputTokenCount();
        int out = usage.outputTokenCount() == null ? 0 : usage.outputTokenCount();
        double usd = (in * inputPerMtok + out * outputPerMtok) / 1_000_000.0;
        totalUsd.add(usd);
        context.attributes().put("costUsd", usd);          // visible to later listeners of the same call
    }

    public double totalUsd() {
        return totalUsd.sum();
    }
}

Source: LangChain4j — Observability (chat model listeners).

Register it with the model builder’s .listeners(List.of(new CostListener(…​))). Keep prices in configuration, not code: they change more often than the service is deployed.

The caching tiers (response, retrieval, chunk and semantic) with event-driven invalidation follow the concepts of Mendelevitch and Bao, Hands-On RAG for Production (Chapter 4), and Huyen, AI Engineering (Chapter 10, caching), paraphrased; see the bibliography.