Semantic Cache¶
Cache LLM responses by query similarity. Save 30-50% on API costs by serving cached responses for semantically similar queries. All embedding runs locally by default.
from promptise import build_agent, SemanticCache, CallerContext
from promptise.config import HTTPServerSpec
cache = SemanticCache()
cache.warmup()
agent = await build_agent(
servers={"tools": HTTPServerSpec(url="http://localhost:8000/mcp")},
model="openai:gpt-5-mini",
cache=cache,
)
# First call → LLM, result cached
result = await agent.ainvoke(input, caller=CallerContext(user_id="user-42"))
# Second similar call → cache hit, no LLM call, instant response
result = await agent.ainvoke(input, caller=CallerContext(user_id="user-42"))
Not legal or compliance advice
The information here is general technical information, not legal, regulatory, or compliance advice. Descriptions of any law, regulation, or standard (such as the GDPR, the EU AI Act, HIPAA, SOC 2, or PCI DSS) are simplified and may be incomplete, out of date, or inaccurate, and requirements vary by jurisdiction and situation. Promptise Foundry makes no warranty as to the accuracy or completeness of this content and is not responsible for how you use or rely on it. Using Promptise does not by itself make you or your product compliant with any law or standard. Consult a qualified lawyer or compliance professional before acting on anything here.
How It Works¶
- User sends a message
- Input guardrails scan the message (block injection attacks)
- Memory search runs (so the cache key includes memory context)
- Cache check — embed the query, search for similar cached queries with matching context
- Cache hit → run output guardrails on cached response → return instantly. The result holds this request's messages followed by the cached answer; the cache stores only the final answer, never the messages, injected context or tool results of the request that produced it
- Cache miss → continue to tools, LLM → output guardrails → cache the post-guardrail response → return
Cache check runs after input guardrails and memory search (so the cache key reflects current memory state) but before tool selection and LLM call. Cached responses are stored after output guardrails — only safe, redacted content is ever persisted in the cache.
What Gets Cached¶
By default the cache only holds answers that are safe to replay:
| Request | Cached by default | Option |
|---|---|---|
| One user message, no tool calls | Yes | — |
Earlier turns in messages (a follow-up) |
No — not looked up, not stored | cache_multi_turn=True |
| The agent called read-only tools | No | cache_tool_turns=True |
| The agent called a write tool | Never | — |
| The agent called a tool behind an approval gate | Never | — |
Follow-ups. "What river runs through it?" means the Seine in a conversation about Paris and the Thames in one about London. With the default cache_multi_turn=False, a request whose messages carry earlier user, assistant or tool messages bypasses the cache (system messages don't count). With cache_multi_turn=True it is cached, and the earlier messages — their role, content and tool calls — are hashed into the cache key, so a follow-up only hits for the same conversation history.
Tool turns. A cached answer replaces the whole turn, tool calls included: the tool does not run. That is never acceptable for a write — a repeated "open a ticket" would answer "T-103 opened" without opening anything — so a turn that called a write tool is never stored. Answers built from read-only tools go stale when the data changes, so they are only cached with cache_tool_turns=True; write invalidation then evicts them when the agent writes, and ttl_patterns bound how long they live otherwise.
Approval. An approval is a decision about one call. A turn that called a tool gated by build_agent(approval=ApprovalPolicy(...)) — approved or denied — is never cached, so a repeated question asks the reviewer again instead of replaying the earlier outcome. Gates enforced by an MCP server (approval through elicitation) are invisible to the cache; don't list such tools in read_only_tools or annotate them readOnlyHint=True.
cache = SemanticCache(
cache_tool_turns=True, # cache answers from read-only tools
read_only_tools=["search_docs"], # extra_tools without MCP annotations
write_tools=["sync_*"], # never trust these as read-only
)
Security: Per-User Isolation¶
Default scope is per_user — every user gets an isolated cache partition. User A's cached responses are invisible to User B.
No CallerContext = no caching. If you don't pass caller=CallerContext(user_id=...), caching is silently disabled for that request. This prevents accidental cross-user data leakage.
# ✅ Cached (user isolated)
result = await agent.ainvoke(input, caller=CallerContext(user_id="user-42"))
# ❌ Not cached (no identity)
result = await agent.ainvoke(input)
Three scopes:
| Scope | Behavior | Use case |
|---|---|---|
per_user (default) |
Each user has their own cache | Any personalized agent |
per_session |
Each session has its own cache | Conversation-specific |
shared |
All users share one cache | Public knowledge (weather, docs, FAQ) |
# Shared scope — requires explicit acknowledgment
cache = SemanticCache(scope="shared", shared_data_acknowledged=True)
Standalone / Shared Mode (No Multi-User)¶
If you're building a single-user app, internal tool, or public FAQ agent where there's no concept of "users," use scope="shared":
cache = SemanticCache(scope="shared", shared_data_acknowledged=True)
agent = await build_agent(
servers={...}, model="openai:gpt-5-mini", cache=cache,
)
# No CallerContext needed — works immediately
result = await agent.ainvoke({"messages": [{"role": "user", "content": "What is Python?"}]})
With scope="shared", caching works without CallerContext. Everyone shares the same cache. Use this when:
- Your agent answers public knowledge questions (docs, FAQ, weather)
- There's only one user (CLI tools, internal scripts)
- Responses never contain personalized data
Shared scope = no isolation
Every user sees everyone else's cached responses. Never use shared scope when the agent accesses user-specific data (accounts, orders, personal info).
Multi-User Mode¶
For apps with multiple users (SaaS, customer support, multi-tenant), the default per_user scope isolates each user's cache automatically:
cache = SemanticCache() # scope="per_user" is the default
agent = await build_agent(..., cache=cache)
# Each user has their own cache partition
await agent.ainvoke(input, caller=CallerContext(user_id="alice")) # Alice's cache
await agent.ainvoke(input, caller=CallerContext(user_id="bob")) # Bob's cache (separate)
How it works internally:
- Cache key prefix is user:{user_id} — Alice's entries are keyed user:alice, Bob's are user:bob
- With CallerContext(tenant_id="acme") the key is tenant-qualified — an injective, colon-prefixed hash (user:t:<sha256>) disjoint from the untenanted namespace, so two tenants with the same user_id can never share a cache partition
- Similarity search only runs within a user's own partition — no cross-user matching possible
- If no CallerContext is provided, caching is silently disabled for that request (with a debug log: "Cache: no CallerContext or user_id provided")
- purge_user("alice") removes all of Alice's cached entries (GDPR compliance)
Per-session mode isolates even further — each of a user's sessions has its own cache:
cache = SemanticCache(scope="per_session")
caller = CallerContext(
user_id="alice",
metadata={"session_id": "sess_abc123"},
)
await agent.ainvoke(input, caller=caller)
A session partition belongs to the user and session together, so two users who pass the same session_id never share entries. A caller without a user_id is not cached, as with per_user; a caller without a session_id uses the user's partition. purge_user() removes the user partition only — per-session entries expire with their TTL — and a write evicts only the session it happened in.
Configuration¶
cache = SemanticCache(
backend="memory", # "memory" or "redis"
similarity_threshold=0.92, # 0.0-1.0 (higher = stricter matching)
default_ttl=3600, # seconds
scope="per_user", # "per_user", "per_session", "shared"
max_entries_per_user=1000,
max_total_entries=100_000,
invalidate_on_write=True, # evict cache when write tools fire
cache_multi_turn=False, # follow-ups bypass the cache
cache_tool_turns=False, # turns that called tools are not cached
ttl_patterns={ # regex → TTL for time-sensitive queries
r"current|now|today|latest": 60,
r"price|stock|rate": 30,
},
)
| Parameter | Type | Default | Description |
|---|---|---|---|
backend |
str |
"memory" |
"memory" or "redis" |
redis_url |
str |
None |
Redis connection URL |
embedding |
EmbeddingProvider \| str |
Local model | Embedding provider or model name |
similarity_threshold |
float |
0.92 |
Min cosine similarity for cache hit |
default_ttl |
int |
3600 |
Default time-to-live in seconds |
scope |
str |
"per_user" |
Cache isolation scope |
max_entries_per_user |
int |
1000 |
Max entries per scope partition |
max_total_entries |
int |
100_000 |
Max entries across all scopes |
encrypt_values |
bool |
False |
AES encryption at rest (Redis) |
ttl_patterns |
dict |
None |
Regex → TTL overrides |
invalidate_on_write |
bool |
True |
Evict the caller's cached answers when a write tool runs |
cache_multi_turn |
bool |
False |
Cache requests with earlier turns; the history is part of the key |
cache_tool_turns |
bool |
False |
Cache answers from turns that called only read-only tools |
write_tools |
list[str] |
None |
Tool names (* wildcards) always treated as writes |
read_only_tools |
list[str] |
None |
Tool names (* wildcards) treated as read-only |
Cache Backends¶
In-Memory (default)¶
Zero dependencies. Sub-millisecond lookups. Lost on restart. Single-process only.
Redis¶
Shared across workers and servers. Survives restarts. Optional AES encryption at rest.
cache = SemanticCache(
backend="redis",
redis_url="redis://localhost:6379",
encrypt_values=True, # AES encryption — set PROMPTISE_CACHE_KEY env var
)
Requires pip install redis. Encryption requires pip install cryptography.
Set PROMPTISE_CACHE_KEY to a Fernet key for persistent encryption across restarts. If not set, a key is auto-generated per process (cache won't survive restart).
Graceful degradation: If Redis is unreachable, cache operations fail silently (logged as warnings) and the agent continues normally — LLM is called directly.
Embedding Providers¶
Local (default)¶
Uses sentence-transformers — the same model used for semantic tool optimization. Zero API calls, runs locally. It needs sentence-transformers and numpy (pip install "promptise[all]"); if either is missing, build_agent(cache=...) raises ImportError naming the package, rather than building an agent that never caches. Call cache.warmup() at startup to load the model before the first request.
cache = SemanticCache() # all-MiniLM-L6-v2
cache = SemanticCache(embedding="BAAI/bge-small-en-v1.5")
cache = SemanticCache(embedding="/models/local/custom")
OpenAI / Azure OpenAI¶
from promptise import OpenAIEmbeddingProvider
cache = SemanticCache(
embedding=OpenAIEmbeddingProvider(
model="text-embedding-3-small",
api_key="${OPENAI_API_KEY}",
),
)
Custom provider¶
Any object implementing the EmbeddingProvider protocol:
class MyProvider:
async def embed(self, texts: list[str]) -> list[list[float]]:
return my_model.encode(texts)
cache = SemanticCache(embedding=MyProvider())
Cache Key¶
The cache key determines when a hit occurs. It includes:
| Component | What it prevents |
|---|---|
Scope prefix (user:42) |
Cross-user data leakage |
| Query embedding | Semantic similarity matching |
| Context fingerprint | Stale answers after memory changes; one conversation's follow-up answering another's |
| Model ID | Serving GPT responses as Claude responses |
| Instruction hash | Stale responses after prompt updates |
The context fingerprint hashes the injected memory content and every message before the query — system messages, and with cache_multi_turn=True the earlier turns — not just how many there are.
With a FallbackChain the model ID is the chain member that is serving: answers are stored under the model that wrote them, and looked up under the first model whose circuit is not open. An answer from a fallback model is never served as the primary's.
If any component changes, the cache misses and a fresh LLM call is made. A similar entry stored under a different context does not hide one that matches: the closest entry with the same context, model and instructions is served.
Write Invalidation¶
When the agent calls a write tool, every cached answer in the caller's scope is evicted, so nothing computed before the write is served after it:
"How many tickets are open?" → cached "47" (cache_tool_turns=True)
create_ticket() runs → cache evicted
"How many tickets are open?" → fresh LLM call → "48"
What counts as a write. MCP servers describe their tools with annotations; a tool is read-only when it declares readOnlyHint: true (@server.tool(read_only_hint=True) in Promptise's server SDK). Any other tool — including one with no annotations, the MCP default — counts as a write. Override per tool name:
read_only_tools=[...]— read-only whatever the annotations say; use it forextra_toolsand other tools without annotations.write_tools=[...]— always a write; wins overread_only_toolsand annotations. Annotations come from the server, so list a tool here if you don't trust its server.
When. The eviction happens as soon as the write tool finishes or fails — before the agent writes its answer, and even if the run fails afterwards. It also happens for runs made with astream() and astream_with_tools(). A request in the same scope that was already running when the write landed does not store its answer, since it may have read the old data. Writes made outside the agent are not seen: bound them with default_ttl and ttl_patterns.
To evict by hand, for example after a webhook reports a change: await cache.invalidate_for_write("orders_updated", caller=caller).
Disable with invalidate_on_write=False if your tools don't affect query results. Turns that called a write tool are still never cached.
GDPR Compliance¶
# Delete all cached data for a user
count = await cache.purge_user("user-42")
# Tenant-scoped callers: purge exactly that tenant's scope
count = await cache.purge_user("user-42", tenant_id="acme")
Observability¶
With observe=True, cache events appear in the observability timeline:
cache.hit— response served from cache;metadatacarriessimilarity(cosine similarity to the stored query),age_seconds,scopeandttlcache.miss— no cache hit, proceeding to LLMcache.store— new response stored in cache
A cache failure (Redis unreachable, embedding API down) never fails the request: it is logged as a warning on the promptise.cache or promptise.agent logger and the request goes to the LLM.
await cache.stats() returns a CacheStats with hits, misses, stores, evictions and hit_rate. Each looked-up request counts once, as a hit or a miss; a request that bypasses the cache (a follow-up with cache_multi_turn=False) counts as neither.
What's Next?¶
- Guardrails — output guardrails always run on cached responses
- Tool Optimization — shares the same embedding model
- Building Agents — the
cacheparameter onbuild_agent()
Learn the concept
- LLM Semantic Cache and Model Fallback in Python -- How a semantic cache cuts LLM cost, with real hit rates.