When AI writes business code, it has a very typical tendency:

async def get_user(user_id: int):
    return await user_db.get_by_id(user_id)

Functionally correct. But if every read in the project becomes:

Read user → Database
Read permissions → Database
Read model → Database
Read Agent → Database
Read knowledge base → Database
Read config → Database

The system soon enters a state where:

Every function is correct, yet the overall architecture grows increasingly inefficient.

The problem is not merely “whether there is a cache”, but:

The AI never first asks: what I want to reuse — a “value”, or an “already-built runtime object”?

This is exactly what’s most worth borrowing from miniagent’s current caching architecture.

It doesn’t just build one cache — it explicitly splits into two categories:

Object Cache
Object caching

Value Cache
Value caching

These two solve fundamentally different problems.


📌 Technology Card

Cache

Temporarily stores content that is expensive to obtain and may be reused, in order to reduce repeated construction, repeated computation, or repeated access to the underlying storage.


Object Cache

Caches runtime objects that have already been built, such as:

AgentRunner, Pipeline, Router, Manager.

It solves:

Don’t repeatedly create expensive objects.


Value Cache

Caches plain data, such as:

permission sets, query results, JSON, strings, computed results.

It solves:

Don’t repeatedly query and recompute.


Multi-Level Storage

Based on speed, capacity, cost, and durability, places data at different tiers, for example:

L1 local memory → L2 Redis → Database.

Here:

L1 (Level 1) is usually process memory;

L2 (Level 2) is usually a shared cache like Redis;

Database serves as the ultimate authoritative data source.


💡 An Intuitive Way to Understand It: A Cache Is Not Just a “Warehouse”, but Also an “Assembled Machine”

Imagine a software system as a factory, and the database as the raw-materials warehouse.

If every time an employee needs a power drill, they run to the warehouse:

Pick up parts
Assemble the motor
Attach the drill bit
Test it
Use it

That’s clearly wasteful. The more sensible way is:

First time:
Warehouse → Assemble the drill → Use it

From then on:
Just grab the ready-made drill → Use it

This is:

Object caching.

Another case:

The employee just wants to check:

How much inventory is left today?

First trip to the warehouse confirms:

Stock = 128

If others ask the same question shortly after, there’s no need for everyone to re-inventory the warehouse.

You can just note it down:

Stock cache = 128

This is:

Value caching.

So although the two caches share a similar name, they are fundamentally different:

Object cache
→ caches "already-assembled things"

Value cache
→ caches "already-looked-up data"

This is exactly miniagent’s dual-track caching philosophy.


Why Is “Just Add Redis” Not Enough?

Many AIs, when faced with caching problems, swing to another extreme:

“We can add Redis.”

Redis is indeed widely used, but it can’t solve every caching problem.

For example, in miniagent:

AgentRunner
SmartRouter
WebSearchPipeline
KBRetrievalPipeline
VectorStoreManager

These are not plain JSON. Internally they may hold:

LLM Client
Tool instances
Repository references
asyncio.Lock
Database objects
Runtime state
Pipeline objects

Such objects usually:

Cannot be simply serialized
Cannot be directly reused across processes
Depend on current-process resources

So they are better suited to:

Process-Local Object Cache

rather than:

Redis.set("agent_runner", ...)

So the first question the caching architecture should answer is not:

Should we use Redis?

but:

What exactly am I caching?


1. miniagent’s Dual-Track Cache Architecture

Currently miniagent explicitly maintains two separate cache-management systems inside ServiceContainer:

miniagent’s two cache architectures

self.cache_registry = ObjectCacheRegistry()

self.object_cache_invalidator = ObjectCacheInvalidator(
    self.cache_registry
)

from app.infra.cache.store_registry import (
    cache_registry as value_cache_registry
)

self.value_cache_registry = value_cache_registry

1. Object Cache: Solving “Don’t Repeatedly Build”

What is the Object Cache suited for?

Typical scenarios:

AgentRunner
WebSearchPipeline
SQLAgent
SmartRouter
KBRetrievalPipeline
VectorStoreManager

The construction of these Runtime Objects may involve:

Read database config
Create LLM Client
Load tools
Assemble Pipeline
Bind Repository
Establish Runtime
Complete initialization

If every request does this from scratch:

Request
build()
use()
destroy

Then a lot of CPU, I/O, and initialization cost is wasted on repeated construction.

So miniagent’s approach is:

First use
No cache
Build the object
Store it in the Object Cache
Reuse it directly from then on

The core of the Object Cache: AsyncLazyCache

miniagent’s object cache core lives at:

app/runtime/cache/lazy_cache.py

Where:

AsyncLazyCache[K, V]

provides:

get_or_build()

The semantics are:

Return it if it exists; otherwise build it.

Core logic:

async def get_or_build(self, key, *args, **kwargs):

    if key in self._store:
        return self._store[key]

    ...

    value = await self._builder(
        key,
        *args,
        **kwargs
    )

    self._store[key] = value

    return value

The flow can be understood as:

flowchart TB A[Request Runtime Object] --> B[Object Cache] B --> C{Exists?} C -->|Yes| D[Return] C -->|No| E[Builder] E --> F[Build object] F --> G[Write to cache] G --> D

The Object Cache Must Handle “Build Breakdown”

Suppose two requests simultaneously need:

AgentRunner(agent_id=10)

and the object hasn’t been created yet.

If you only do:

if key not in cache:
    cache[key] = await build()

You may end up with:

Request A → build
Request B → build
Request C → build

The same expensive object gets created three times.

This is essentially a case of:

Cache Breakdown

except here what’s being broken through is not the database, but:

the expensive object-construction process.

miniagent’s AsyncLazyCache uses a per-key:

asyncio.Lock()

and double-checked locking:

async with self._locks[key]:

    if key in self._store:
        return self._store[key]

    value = await self._builder(...)

So:

flowchart TB A[Request A] --> D[Same Key] B[Request B] --> D C[Request C] --> D D --> E[Single Flight] E -->|Acquires lock| F["Request A
Builds the object"] E -->|Waits| G["Request B / C
Waiting"] F --> H[Write to cache] H --> I["Request A
Returns object"] H --> J["Request B / C
Directly reuse cached object"] G --> J

Here:

Single Flight

means:

For the same key, only one task at a time is allowed to be responsible for loading or building.

This is a very important design in miniagent’s object cache.


Why Doesn’t the Object Cache Usually Rely on TTL?

This is where object and value caches clearly diverge.

For example:

AgentRunner

doesn’t become invalid just because:

1 hour is up

What typically invalidates it is:

Agent config changed
LLM changed
Tool changed
KB changed
Embedding changed
Router config changed

In other words:

An object’s validity mainly depends on “whether its dependency config has changed”.

So it’s better suited to:

Event-Driven Invalidation

rather than:

TTL (Time To Live)


How Does miniagent Invalidate Object Caches?

miniagent specifically designed:

CacheInvalidationService

Located at:

app/runtime/cache/invalidation.py

For example, when an Agent config changes:

def on_agent_changed(self, agent_id):

    if agent_id:
        self.registry.invalidate(
            CacheType.AGENT_RUNNER,
            agent_id
        )

So:

Agent Changed
AgentRunner is now stale
Invalidate
Next call
Rebuild

This is the classic:

Config-driven runtime rebuild.


Object-cache invalidation is really “dependency-graph governance”

For example, an LLM config change.

It may affect not only:

LLM Runtime

but also:

WebSearchPipeline
SQLAgent
AgentRunner
KBRetrievalPipeline

So miniagent uniformly does:

def on_llm_changed(self):

    self.registry.invalidate_all(
        CacheType.WEB_SEARCH_PIPELINE
    )

    self.registry.invalidate_all(
        CacheType.SQL_AGENT
    )

    self.registry.invalidate_all(
        CacheType.AGENT_RUNNER
    )

    self.registry.invalidate_all(
        CacheType.KB_RETRIEVAL_PIPELINE
    )

Essentially:

flowchart TB A[LLM Config Changed] --> B[Dependency Graph] B --> C[Web Search] B --> D[SQL Agent] B --> E[Agent Runner] B --> F[KB Retrieval]
This is far safer than:

cache.clear()

Because it knows:

Which objects depend on which configs.


2. Value Cache: Solving “Don’t Repeatedly Query Data”

The value cache is the one we’re most familiar with.

It caches:

Permission Set
Query Result
...

Its core goal is:

Avoid repeated database queries or repeated computation.


The Value Cache Uses Cache-Aside

A typical value-cache flow:

flowchart TB A[Request] --> B[Value Cache] B --> C{Hit?} C -->|Yes| D[Return] C -->|No| E[Database] E --> F[Obtain value] F --> G[Write to cache] G --> D

This pattern is usually called:

Cache-Aside Pattern

The application itself controls:

Read Cache first
On miss, read Database
Then write Cache

miniagent’s Value Cache Infrastructure

It lives at:

app/infra/cache/
├── factory.py
├── memory.py
└── store_registry.py

Created uniformly through:

create_cache_backend(...)

For example:

create_cache_backend(
    namespace="auth",
    backend_type="memory",
)

Currently it supports:

MemoryCacheStore

While reserving an interface for the future:

Redis

This means business code depends on:

the caching capability

rather than:

a specific caching product.


The Value Cache Needs LRU

The easiest way for a value cache to run out of control is:

cache[key] = value

and then never delete anything.

So:

100
1000
10000
100000
...

keeps growing.

So miniagent’s MemoryCacheStore uses:

LRU (Least Recently Used)

self._cache = LRUCache(
    maxsize=max_size
)

When the cache is full:

The least-recently-used data is evicted first.


The Value Cache Needs TTL

The value cache also faces:

Data can go stale.

For example, user permissions.

The database has been updated, but the cache still holds the old value.

So miniagent provides:

mset_with_ttl()
mget_ttl()

Through:

TTL (Time To Live)

it limits the maximum usable time of the data.

For example:

TTL = 3600 seconds

After expiration:

Cache Miss
Re-query the database
Re-cache

This is very reasonable for ordinary data caching.


miniagent’s Permission Cache Is a Typical Value Cache

In AuthPermission:

cached = self._cache.mget_ttl(
    [self._cache_key(user_id)]
)[0]

if cached is not None:
    return self._decode(cached)

return await self._load_permissions(user_id)

That is:

flowchart TB A[Permission Request] --> B[Value Cache] B --> C{Hit?} C -->|Yes| D[Return permissions] C -->|No| E[Database] E --> F[User → Role → Permission] F --> G[Write to cache] G --> D

This avoids:

Re-querying the RBAC permission chain on every API request.


A Value Cache Needs Not Only TTL, but Also Active Invalidation

TTL alone isn’t enough.

For example, an admin just revoked a user’s permissions.

If:

TTL = 3600 seconds

The old permissions could theoretically persist for nearly an hour.

So you also need:

Invalidate Cache

When permissions change:

Database Update
Invalidate Value Cache
Next request
Cache Miss
Reload latest permissions

So a complete value cache must consider:

Read
Write
TTL
Invalidate
Capacity
Stats

rather than only:

get / set

The Value Cache Also Faces Cache Penetration

Suppose someone repeatedly requests:

user_id = 999999999

The cache doesn’t have it:

MISS

The database doesn’t have it either.

Next time:

MISS
Database

Still nothing.

This is:

Cache Penetration

That is:

Querying data that doesn’t exist in the first place, causing requests to keep passing through the cache and hitting the database.


How to Handle Cache Penetration?

One simple approach:

Negative Cache (empty-value caching)

For example:

user:999999999 = NOT_FOUND
TTL = 60 seconds

Next time:

Cache Hit
NOT_FOUND
Return directly

The database doesn’t need to be queried again.

The TTL for empty-value caching should usually be shorter.

For example:

Normal value: 3600 seconds
Empty value: 60 seconds

To avoid blocking newly created data for too long with the stale empty value.


The Value Cache Also Faces Hot-Key Breakdown

Suppose:

system_config

is a high-frequency hot key.

Normally:

1000 Requests
Cache

Suddenly the TTL expires:

Cache Expired

So:

Request A ─┐
Request B ─┤
Request C ─┤
...        ├──→ Database
Request N ─┘

This is:

Cache Breakdown

The solution can still use:

Single Flight / Per-Key Lock

That is:

Lots of misses
Only one request with the same key is allowed to go back to source
Other requests wait
Cache is rebuilt
All return uniformly

miniagent’s Object Cache already natively embodies this Single-Flight idea.

In the future, if certain value caches become high-concurrency hot spots, the same approach can be reused.


Cache Avalanche Is Also More Typical of Value Caches

If the AI uniformly sets for all caches:

ttl = 3600

And a lot of data is written around the same time:

Written at 11:00
Expires en masse at 12:00

You may get:

Cache Avalanche

That is:

A large number of caches expire at once, and a flood of requests instantly hits the database.

Mitigation methods include:

TTL Jitter
Random TTL variation

Multi-Level Cache
Multi-tier caching

Rate Limiting
Throttling

3. Why Is the Object Cache Unsuitable for Redis, While the Value Cache Is a Great Fit?

Look at the simplest comparison.

AgentRunner

May contain:

LLM Client
Tool
asyncio.Lock
Repository
Pipeline
Runtime State

Characteristics:

Complex
Not directly serializable
Depends on the current process

Suited to:

Local Memory

Permission Set

For example:

[
  "system:user:list",
  "system:user:create"
]

Characteristics:

Simple
Serializable
Shareable across processes

Suited to:

Memory
Redis

So when miniagent scales horizontally in the future, the more reasonable approach is:

Object Cache
→ Each process still keeps its own Runtime

Value Cache
→ Can evolve into a shared Redis cache

That is the design that respects the nature of the objects.


4. miniagent Also Keeps the Two Caches’ Registries Separate

Object Cache Registry

Responsible for:

Which Runtime Caches exist?
Which Runtime Object to invalidate?
Invalidate all?
Invalidate by condition?
Inspect the Runtime Cache status?

Corresponds to:

CacheRegistry

Supports:

invalidate
invalidate_all
invalidate_where
stats
list_names

Value Cache Registry

Responsible for:

Which namespaces exist?
What the underlying backend is?
How many keys right now?
What's the hit rate?
Which keys to delete?
Which namespace to clear?

Corresponds to:

CacheStoreRegistry

The responsibilities of these two registries are in fact very clear:

Object Registry
→ Runtime lifecycle governance

Value Registry
→ Key-Value data governance

5. Caches Must Be Observable

Another thing AI easily overlooks when adding caches:

Is the cache actually working?

You should at least know:

Hits
Misses
Hit Rate
Current Size
TTL Expirations

miniagent’s MemoryCacheStore already maintains:

self._hits
self._misses
self._ttl_expirations

And through:

get_stats()

returns:

current_size
hits
misses
hit_rate
ttl_expirations

So a cache is not:

“It should feel a bit faster now.”

It should be able to answer:

How much was actually hit?


Scene 1: Everything Queries the Database Directly

return await repository.get(id)

Problem:

The AI never judged whether the data is high-frequency, expensive, or already cached.


Scene 2: A Complex Runtime Gets Re-Created Every Time

runner = await build_agent_runner(agent_id)

Every request rebuilds.

Problem:

The AI didn’t realize this isn’t an ordinary object, but a reusable Runtime.


Scene 3: All Caches Become One dict

cache = {}

Then:

AgentRunner
Permission
Search Result
Config

all get stuffed in.

Problem:

Object lifecycles and data lifecycles are completely different, yet get conflated.


Scene 4: See a Cache, Reach for Redis

"Just add Redis."

Problem:

Runtime Objects are simply not suitable for cross-process serialization.


Scene 5: Cache but Never Invalidate

cache[key] = value

And then never touch it again.

Result:

The database changed; the cache is still alive.


Scene 6: Mechanical TTL on the Object Cache Too

For example:

AgentRunner TTL = 1h

Problem:

The Agent config didn’t change, yet it gets rebuilt for nothing.

Or:

The Agent config changed long ago, but the TTL hasn’t expired yet.

The correct approach should be:

Config change
→ Precise invalidation

Scene 7: All Value Caches Are Treated the Same

TTL = 3600
max_size = 1000

Copied into every scenario.

But in reality:

Permissions
Search results
Config
Temporary computation results

have completely different access patterns.

The caching strategy shouldn’t be identical either.


2. Establish Clear Caching Rules for the AI

You can add the following to your Project Rules.

## Cache Architecture

miniagent uses two different caching systems:
1. Object Cache
2. Value Cache

Never conflate their responsibilities.

### Object Cache

The Object Cache stores expensive runtime objects, such as:
- AgentRunner
- Pipelines
- Routers
- Vector store managers
- Runtime components

Rules:
- Use AsyncLazyCache.
- Use lazy construction.
- Use the single-flight pattern to prevent duplicate concurrent construction.
- Keep runtime objects process-local.
- Do not serialize runtime objects into Redis.
- Prefer event-driven invalidation.
- When a dependency config changes, invalidate the affected runtime objects.

### Value Cache

The Value Cache stores serializable values, such as:
- Permission sets
- Query results
- Computed results
- Simple data objects

Rules:
- Use the shared cache backend abstraction.
- Use namespaces to isolate different domains.
- Apply capacity limits, e.g. LRU (Least Recently Used).
- Use TTL (Time To Live) where data may go stale.
- Invalidate relevant keys after important writes.
- Consider negative caching for missing data.
- Protect hot keys from cache breakdown when necessary.
- A distributed backend (e.g. Redis) should belong here.

### Storage Hierarchy

The Value Cache may evolve into:

L1 Memory
→ L2 Redis
→ Database

The Object Cache is an independent runtime-lifecycle system,
and should not be treated as another L1/L2 value-cache layer.

It can be summarized as:

Complex runtime objects go through the Object Cache; ordinary data goes through the Value Cache.

Invalidate the object cache on config changes; invalidate the value cache via TTL and data changes.

Just because both are called “Cache” doesn’t mean the AI should handle them with the same logic.


3. Putting It into Prompts: Don’t Just Say “Add a Cache”

Bad prompt:

Add a cache to this feature.

The AI has no idea which kind it should be.

A better prompt:

First determine whether this scenario calls for the Object Cache or the Value Cache.

If you're caching a Runtime Object that is expensive to build:

1. Reuse the existing AsyncLazyCache;
2. Use get_or_build;
3. Preserve Single Flight;
4. Don't introduce TTL as the primary invalidation method;
5. Clearly state which configs it depends on;
6. On config changes, precisely invalidate via ObjectCacheInvalidator.

If you're caching ordinary serializable data:

1. Reuse the existing Value Cache Backend;
2. Use an independent namespace;
3. Clearly state the key;
4. Clearly state max_size;
5. Clearly state the TTL;
6. Clearly state the invalidate path after data changes;
7. Determine whether cache penetration exists;
8. Determine whether a hot key carries breakdown risk;
9. Don't privately create dict/LRU caches inside business code.

This way, before doing anything, the AI first makes the single most important architectural decision:

Am I caching an object, or a value?


Summary

What caching and multi-level storage really need to correct is not only:

AI blindly querying the database.

It also includes another equally common problem:

AI blindly re-creating expensive objects.

So in miniagent, the more accurate caching philosophy is:

Object Cache
solves "don't repeatedly build"

Value Cache
solves "don't repeatedly query"

The object cache uses:

AsyncLazyCache
+
Lazy Build
+
Single Flight
+
Event Invalidation

to manage the Runtime Object lifecycle.

The value cache uses:

Cache Backend
+
Namespace
+
LRU
+
TTL
+
Invalidate
+
Stats

to reduce database and computation pressure.

And the Value Cache can naturally evolve in the future into:

L1 Memory
L2 Redis
Database

That is a complete and clear caching and multi-level storage architecture.

For AI programming, what should truly be written into the rules is not:

“Remember to use caching.”

but:

First determine whether you’re repeatedly “building objects” or repeatedly “querying data”; then use the corresponding cache system, rather than casually stuffing a dict into the business code.


Open Source Code


🪐 Good luck 🪐