Skip to content

Caching Strategy

Companion to the README. Covers every cache layer in core-ai + the provider-specific prompt-caching layers. Read once; refer back whenever you tune cost.


Why caching matters here

Large-language-model APIs charge per token. Every repeated read, every retry, and every byte that crosses the wire burns dollars. The package bakes four classes of cache into one pipeline:

  1. Provider-discovery caches (models, usage, pricing) — meta caches. Mostly time-of-day deterministic.
  2. Response cache (v2.1.0) — application-level memoisation of invoke/converse results.
  3. Embedding cache (v2.1.0) — memoisation of embed() per text.
  4. Provider prompt-cache (v2.1.0) — wire-format markers that the upstream provider honours for cheaper input tokens.

Plus one HTTP-layer safety net:

  1. Retry-After honouring — capture upstream rate-limit hints in the HTTP path, prefer over exponential backoff in withRetry().

This guide is dense by design. Use the table of contents.


1. The cache key contract

Every cache layer uses SHA256 content hashes so cache keys are deterministic across processes, queues, and machines. Hit the cache with the same (modelId, systemPrompt, userMessage, maxTokens, temperature) tuple; you get a hit. Change any field; cache miss.

LayerKey shapeValue shape
models_ttl{platform}_models_*Provider model catalog.
usage_ttl{platform}_usage_*Aggregated usage metrics.
pricing_ttl{platform}_pricing_*Pricing tier table.
response_ttl (v2.1.0){platform}_response_{sha256(model|system|user|max|temp)}Full invoke result.
response_ttl (multi-turn){platform}_response_{sha256(model|system|json_messages|max|temp)}Full converse result.
embedding_ttl{platform}_embeddings_{sha256(modelId|dimensions|text)}{text: float[]} row.
idempotency-key (v2.1.0){platform}-{sha256(modelId|content)}Not stored — passed upstream.

{platform} is the lowercased platformName() output plus _ai ("aws_bedrock_ai", "azure_openai_ai"), which is why cache keys for one provider don't collide with another.


2. Response cache (cache.response_ttl, v2.1.0)

The single most impactful cost lever for deterministic prompts.

When to enable

  • Re-ingestion of structured data (CSV → JSON, JSON → JSON).
  • Templated replies (summarise this report, classify this support ticket).
  • Anywhere the same (model, system, user, max, temp) is sent more than a few times per TTL window.

When NOT to enable

  • Live chat with rolling history.
  • Streaming responses (the package deliberately does not memoise streaming — converseStream() bypasses the cache).
  • Any prompt whose target output mutates per call (random seed, current date).

Configuration

php
// config/core-ai.php
'cache' => [
    'response_ttl' => 3600, // memoise for 1 hour
],

Set 0 to disable (the default).

Bypassing the cache

Two options:

  1. Set cache.response_ttl to 0 for that environment.
  2. Send a unique request — change userMessage, temperature, or maxTokens by even one unit. The SHA256 hash will not match and the cache miss path runs.

Cost math

For a static system prompt + 1k users each making 10 calls in an hour, all hitting the same prompt:

  • Without cache: 10,000 model calls × full input rate.
  • With cache: 1 model call × full rate + 9,999 hits × 0 cost (zero SDK round-trip, zero tokens billed).

Cache hits still pass through fireInvokedEvent(). Core-ai's AbstractAiManager dispatches the canonical AiInvoked; a provider manager may override that dispatch method, so confirm provider-specific event aliases against the installed provider version.


3. Embedding cache (cache.embedding_ttl, v2.1.0)

Embeddings are deterministic — same model + same text + same dimensions = same vector. The package memoise them per (modelId, dimensions, sha256(text)) for 7 days by default (604800 seconds).

The embed() method is not part of core-ai's AiManagerContract or AbstractAiManager; it is implemented by the ubxty/bedrock-ai and ubxty/azure-ai provider managers. With one of those provider packages installed:

php
// Ubxty\BedrockAi\BedrockManager or Ubxty\AzureAi\AzureManager
$vectors = $manager->embed('amazon.titan-embed-text-v2:0', $corpus, dimensions: 1024);

// First call: hits AWS Bedrock InvokeModel for every text. Caches rows.
// Second call (within TTL) for the same text: cache hit, zero provider spend.

Per-row keys mean you can mix and match: append one text to a 1M-row corpus, only the new row touches the wire.

When to invalidate

You almost never need to. If you change models or dimensions, the modelId + dimensions segment of the key changes and the old rows simply go cold. A 7-day TTL on stale rows is harmless.

If a particular row's source text has changed (you have an updated version), call embed() again with the new text — its hash differs and a new row is written.

Bumping the TTL

Set cache.embedding_ttl in your published config:

php
'cache' => [
    'embedding_ttl' => 30 * 24 * 3600, // 30 days, for slow-moving corpora
],

4. Provider prompt caches (v2.1.0)

Prompt caching is a wire-format feature in the upstream provider SDK. The package injects markers at the configured anchor points; the provider charges ~10% of the normal input-token rate for any prefix that matches a recent marker.

Bedrock (cachePoint)

php
// config/core-ai.php
'bedrock' => [
    'prompt_caching' => [
        'points' => ['system', 'last_user'],
        'ttl_seconds' => 300, // 5 min, max 3600 (1 hour)
    ],
],

Anchors:

  • system — inject a cachePoint: { type: 'default' } block after the system-prompt content.
  • last_user — inject a block after the last user-message content.

Up to 4 cache-points per Converse request (Bedrock limit). The package only adds anchors you configure, so 2 points is well within budget.

400 on unsupported models. Bedrock returns a 400 if a cachePoint is placed on a model that doesn't support prompt caching. If you switch models and start seeing 400 cachePoint is not supported for this model, drop the cache-points from config.

The published core-ai config exposes prompt-cache settings only under core-ai.bedrock.prompt_caching. If another provider version supports upstream prompt caching, use that provider's documented configuration rather than assuming the Bedrock keys apply.

Cost math (worked)

Suppose a Claude 3.5 Sonnet call has:

  • System prompt: 600 tokens.
  • User message: 100 tokens.
  • Output: 200 tokens.

Without prompt cache, the input bill is 700 × $0.003/1k = $0.0021.

With cachePoint at the system anchor and the system prompt being identical across many calls:

  • First call: 700 input tokens × full rate.
  • Subsequent calls within the cache window: 100 × full rate + 600 × 10% of full rate = $0.0003 + $0.00018 = $0.00048.

~77% off the input-token bill on cached calls. The output-token bill is unaffected.


5. Retry-After honouring (v2.1.1)

When the upstream returns 429: Too Many Requests, most AWS / Azure deployments include a Retry-After header in seconds. The package captures it in the HTTP path:

HTTP 429 + Retry-After: 17
  → HTTP path captures 17 → setRetryAfterSeconds(17)
  → withRetry() sees hint, sleeps 17s, clears hint, retries
  → next attempt: if it also 429s, it asks again

If no Retry-After is present (or the previous hint was consumed), withRetry() falls back to baseDelay ** attempt:

attempt 0 → 2 s
attempt 1 → 4 s
attempt 2 → 8 s

Effective wait times after a real 429:

  • With hint: often 5–30 s — the provider's actual cooldown.
  • Without hint: 2 s → 4 s → 8 s — risk of re-429.

The Retry-After path typically recovers in seconds where the exponential path would need 14+ s and still fail.

Tuning

php
'retry' => [
    'max_retries' => 3,
    'base_delay'  => 2, // seconds
],

Provider packages expose their own env-bridged overrides:

dotenv
BEDROCK_MAX_RETRIES=3
BEDROCK_RETRY_DELAY=2
AZURE_OPENAI_MAX_RETRIES=3
AZURE_OPENAI_RETRY_DELAY=2

6. Putting it all together

For a high-volume pipeline (1M calls/day, 600-token system prompt, 100-token user message):

LeverExpected impactEffort
cachePoint on system~77% off input rate on cached calls1 line in config
cache.response_ttl for deterministic prompts100% off on cache hits1 line in config
cache.embedding_ttl for ingestionOne-time cost per text1 line in config
Idempotency-Key for retry safetyEliminates double-billingAutomatic
Retry-After honouringFaster recovery on real rate-limitAutomatic

For the canonical "before vs after v2.1.0" comparison, see /docs/real-world-patterns.md.


7. Cache store

All caches use the default Laravel cache store. Switch to Redis or Memcached the same way you would for any other Laravel cache:

dotenv
CACHE_STORE=redis

The response-cache and embedding-cache layers store row-sized entries (a few KB at most per row). Redis is recommended for production; the file driver works for local development.

Released under the MIT License.