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:
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)
Source: LangChain reference — VectorStore and Redis — Sets.
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):
-
The re-index worker knows which chunk IDs of a document changed or were deleted.
-
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). -
Every cache owner removes the entries that cited those chunks through the reverse index, and drops retrieval-cache entries for the affected filter scope.
-
A model or prompt rollout bumps the
config_versionin 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:
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:
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;
}
}
Source: Spring AI — Advisors API and Spring AI — Observability.
In LangChain4j, a ChatModelListener registered on the model sees every response with its TokenUsage:
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.