Memory, cache and rate limiting
Three parts sit around every call, each behind an interface you can replace:
| Part | Interface | Built in | Builder default | AddIntentSharp default |
|---|---|---|---|---|
| The operation log | ISemanticMemory |
InMemorySemanticMemory, SqliteSemanticMemory |
in process, the last 10,000 calls | in process, the last 10,000 calls |
| The request cache | IRequestCache |
MemoryRequestCache |
none | in process, 1,000 entries |
| The rate limiter | IRateLimiter |
TokenBucketRateLimiter |
none | none |
The cache and the rate limiter live in one process, and the in-process log does too; a SQLite log is a file, so processes given the same path share it. None of them is static: each belongs to the client it is given to. All three are set on the builder:
var client = new IntentClientBuilder()
.UseAnthropic(anthropicKey)
.WithMemory(new SqliteSemanticMemory("intentsharp-log.db"))
.WithCache(new MemoryRequestCache(capacity: 5_000))
.WithRateLimiter(new TokenBucketRateLimiter(requestsPerSecond: 2, burst: 5))
.Configure(options => options.CacheTtl = TimeSpan.FromMinutes(30))
.Build();
WithMemory(null) turns the log off, and WithCache(null) and WithRateLimiter(null) leave those out. The client
does not dispose any of the three.
The operation log records every call
After each call, the client records one entry, whether the call succeeded or not:
| Field | What it holds |
|---|---|
Intent |
The intent |
InputPreview |
The serialized input, cut to its first 2,000 characters |
Output |
The model's last reply, in full |
TargetType |
The name of the result type |
Success, Attempts |
Whether the call succeeded, and how many attempts it made |
Provider, Model |
Who answered |
InputTokens, OutputTokens |
The call's token counts |
Timestamp |
When it was recorded |
Tags |
The call's tags; for a catalog intent, its name, version and file |
Nothing is recorded for a call answered from the cache, for a call with AIRequestOptions.DoNotRecord, or when
IntentClientOptions.RecordInMemory is false. An error while recording is logged at Warning and never fails the
call. The library never reads the log back into a request: it is for you.
Because the log holds inputs and replies, it holds whatever your inputs hold. Keep sensitive calls out of it with
DoNotRecord, per call or with the doNotRecord field of an intent file:
// Ask the model every time, and keep this input out of the operation log.
var options = new AIRequestOptions { BypassCache = true, DoNotRecord = true };
var summary = await patientNote.AIAsync(client, "Summarize this note for the billing team.", options);
Where the log lives
InMemorySemanticMemory(maxEntries)keeps entries in the process and drops the oldest pastmaxEntries, 10,000 by default (0 means no limit). It is what a client gets when you do not callWithMemory.SqliteSemanticMemory(path)keeps them in a SQLite file, created when missing, so they survive a restart.
Reading it
GetStatsAsync() counts the entries, the successful ones, the tokens and the oldest and newest times.
RecallAsync(query, maxResults) returns the entries whose intent is most like the query, most similar first.
ClearAsync() removes every entry.
if (client.Memory is { } log)
{
MemoryStats stats = await log.GetStatsAsync();
Console.WriteLine($"{stats.TotalEntries} calls, {stats.SuccessRate:P0} succeeded, {stats.TotalTokens} tokens");
foreach (var match in await log.RecallAsync("order urgency", maxResults: 3))
Console.WriteLine($"{match.Score:0.00} {match.Entry.Timestamp:u} {match.Entry.Intent} -> {match.Entry.Output}");
}
Recall compares words by default: the share of words of three or more characters that the query and an intent
have in common. Entries with nothing in common are left out. SqliteSemanticMemory compares the most recent
2,000 entries (RecallCandidateLimit).
To recall by meaning instead, give the log a provider that supports embeddings:
// Recall by meaning: each recorded call costs one embeddings call to OpenAI.
var client = new IntentClientBuilder()
.UseAnthropic(anthropicKey)
.WithMemory(new InMemorySemanticMemory(maxEntries: 10_000, embeddingProvider: new OpenAIProvider(openAIKey)))
.Build();
Each recorded call then costs one embeddings call, and so does each recall. These calls are not counted in
TotalModelCalls, not rate-limited and not cached. When an embedding fails, the entry is kept without one and
recall falls back to words for it; pass a logger to the log's constructor to see such failures at Warning.
The request cache answers identical requests without a model call
With a cache, an identical request to the same provider is answered from it while the entry lasts. Only the first attempt of a call reads the cache.
A cached answer is checked like a fresh one, against the current call's rules and checks. One that fails is discarded, the call asks the provider instead, and the fresh reply replaces the entry, so an answer cached under older rules is never served unchecked.
A call answered from the cache has FromCache true, adds no tokens to Usage, does not count as a model call, does
not wait on the rate limiter and is not recorded in the log. A judge, when on, still makes its own call. Refusals
and replies cut off by the output limit are never stored.
| Setting | Default | What it does |
|---|---|---|
MemoryRequestCache(capacity) |
1,000 entries | Past the capacity, the least recently used entry goes. |
IntentClientOptions.CacheTtl |
1 hour | How long an entry lasts. |
IntentClientOptions.UseCache |
true |
False stops this client using its cache. |
AIRequestOptions.BypassCache |
false |
True keeps one call out of the cache: no read, no write. |
GetStats() reports hits, misses, entries and the capacity, and HitRate:
if (client.Cache is { } cache)
{
CacheStats stats = cache.GetStats();
Console.WriteLine($"Cache: {stats.Hits} hits, {stats.Misses} misses, {stats.HitRate:P0}, {stats.Entries}/{stats.Capacity} entries");
}
if (client.RateLimiter is { } limiter)
{
RateLimitSnapshot bucket = limiter.GetSnapshot(client.PrimaryProvider.Name);
Console.WriteLine($"{bucket.Provider}: {bucket.AvailableTokens:0.0}/{bucket.Capacity} free, {bucket.Allowed} allowed, {bucket.Denied} refused");
}
To share a cache between processes, implement IRequestCache: GetAsync(key), SetAsync(key, response, ttl),
ClearAsync() and GetStats(). The key is an opaque string the client computes; store the AIResponse under it
as it is.
The rate limiter spaces out calls to each provider
TokenBucketRateLimiter(requestsPerSecond, burst) keeps one allowance per provider name. It starts full with
burst calls (10 by default), refills at requestsPerSecond (5 by default), and each model call takes one.
- A call waits for its turn for up to 30 seconds. When its turn is further away, it fails at once with
AIRateLimitException, a transient error: the call falls back when a fallback is set, and otherwise ends with aProviderissue. - Judge calls take their turn from the primary provider's allowance.
- Embedding calls do not wait on it.
- It limits this process only. Several instances of an application each have their own.
GetSnapshot(provider) reports the calls that may start now, the burst size, and the calls allowed and refused so
far.
With dependency injection
AddIntentSharp sets up the three from the IntentSharp section: MemoryPath, EnableRequestCache,
CacheCapacity, RateLimitPerSecond and RateLimitBurst, which
Configuration and DI lists with their defaults:
services.AddIntentSharp(settings =>
{
settings.MemoryPath = "intentsharp-log.db"; // SQLite; "" turns the log off
settings.CacheCapacity = 5_000; // the cache is on by default
settings.RateLimitPerSecond = 2; // 0, the default, means no limiter
settings.RateLimitBurst = 5;
settings.Options.CacheTtl = TimeSpan.FromMinutes(30);
});
An implementation of your own that you register in the container is used in place of the built-in one, but only
while its setting is on: to use your IRateLimiter, register it and set RateLimitPerSecond above 0.
Limits
- The built-in cache and limiter live in one process. They are not shared between instances unless you supply implementations that are.
- The log's
InputPreviewholds the first 2,000 characters of the input;Outputholds the whole reply. - Recall compares the query with each entry's intent, not with its input or its output.