Response caching¶
Every product eventually adds a cache in front of its model calls, and the two ways it
goes wrong are always the same: the key is the user's prompt, so a tool-schema change
serves an answer shaped for the old schema, and the tenant is not in the key, so one
customer is served another's answer. CachingProvider is a ModelProvider that wraps
another one, so caching is a change to where the provider is built and nothing else.
from tesserix_adk.models import CachingProvider, MemoryCacheStore
model = CachingProvider(OpenAIProvider("gpt-4o"), MemoryCacheStore(), tenant="acme")
answer = await model.complete(request)
The key is the whole correctness argument¶
An entry is served only when every determinant of the answer matches:
| Determinant | Why a change must miss |
|---|---|
| tenant | Two customers asking the same question must never share an answer. |
| model | A different model is a different answer. |
| prompt | The assembled prompt as it goes on the wire, not the user's text. |
| tool schemas | An answer shaped for the old tools is the wrong shape for the new ones. |
| output schema hash | The same for the output contract. |
| parameters | The call settings you declared. |
| prompt version | Retiring a prompt design stops serving what it produced. |
| model version | A vendor's silent upgrade is a different model. |
The tenant is also structural, not only a field: a CachingProvider is built for one
tenant and has no way to ask the store for another's entry.
What may be cached at all¶
CachePolicy refuses rather than storing:
- sampled calls — a declared
temperatureabove zero, ornabove one. Storing a random draw and serving it as a fact is not caching, it is fabricating determinism; - anything inside
not_cacheable(...)— the paths the request cannot show, such as a personalised memory read, a side-effecting tool's result, or an approval-gated answer:
A refused call is never written to the store and is reported as CacheStatus.REFUSED.
Known limitation: parameters are the ones you declare. The kit cannot see a provider's
own defaults, so a call that samples must say so in parameters= — treating undeclared
settings as non-deterministic would refuse everything anyone ever cached.
Expiry and invalidation¶
ttl_seconds bounds how long an entry may be served; an expired entry is dropped rather
than kept. Retiring a prompt or a model is a key change, so nothing stale is served —
but the old entries are still occupying the store, so remove them:
await model.forget(prompt_version="v3") # what that design produced
await model.forget() # everything for this tenant, for erasure
Stampedes¶
A cold key under concurrent load is one call. The first caller makes it and the rest wait
on the same answer, counted as coalesced. A failed call is not cached and does not leave
the key wedged for the next caller.
Outages degrade, they do not fail¶
A store that cannot be reached is a slow run, never a broken one: the lookup failure is
counted, reported as CacheStatus.STORE_UNAVAILABLE, and the call goes live. A write that
fails is the same. The one thing that is not swallowed is forget — erasure that
silently failed is worse than erasure that failed loudly.
The semantic tier¶
Off unless configured, because approximate matching is a correctness trade a consumer must opt into deliberately:
semantic = SemanticConfig(embedder=embedder, index=MemorySemanticIndex(), model="bge-m3",
threshold=0.97)
A near match is served only at or above the threshold, only within the tenant, and only when it was indexed by the same embedding model — an upgraded embedder is a new vector space, so its entries are invalidated rather than compared across. The threshold and the embedding model are recorded on each entry, so reading one back does not depend on what today's configuration happens to say.
Stores¶
MemoryCacheStore holds entries in the process and nowhere else. RedisCacheStore
(in tesserix_adk.adapters) is the shared one, keyed
<namespace>:<tenant>:<prompt version>:<model version>:<digest> so that every purge
criterion is a key segment and erasure is one pattern rather than a scan of every value.
A shared store holds customers' answers outside the process that produced them, so:
- the model's own reasoning is dropped before writing (
redact_reasoning, on by default) — it is sensitive, never replayed, and a cache is not a place to keep it; - the store itself must be encrypted at rest and in transit. The kit cannot enforce that from inside a client, so it is a deployment requirement rather than a setting here;
- an erasure request runs
forget()for the tenant, which clears the entries and the semantic vectors together.
Status and metrics¶
Pass observer= to receive a CacheOutcome per call — status, key digest, usage saved,
refusal reason, similarity — and record it on the run's trace attributes. metrics totals
hits, semantic hits, misses, refusals, stores, coalesced waits, store failures and the
usage the cache saved.
Runnable version: examples/response_caching.py.