Caching Agent Responses and Tool Results to Cut Redundant Calls
Many agent fleets pay repeatedly for the same FAQ answer and the same search snippet.
Search across all documentation pages
Many agent fleets pay repeatedly for the same FAQ answer and the same search snippet.
Caching repeatable tool results and safe model responses frees RPM, TPM, tool slots, and budget for novel work.
Done poorly, cache keys leak data across tenants or serve stale facts as truth.
Key stable reads by normalized inputs and tenant scope, cache tool observations more aggressively than final answers, set TTLs by freshness needs, and never cache personalized or side-effecting operations without strict isolation.
source=cache so traces stay honest.import hashlib
import json
import time
from dataclasses import dataclass
from typing import Any, Callable
@dataclass
class CacheEntry:
value: Any
expires_at: float
class TtlCache:
def __init__(self) -> None:
self._store: dict[str, CacheEntry] = {}
def get(self, key: str) -> Any | None:
ent = self._store.get(key)
if not ent:
return None
if time.time() >= ent.expires_at:
del self._store[key]
return None
return ent.value
def set(self, key: str, value: Any, ttl_s: float) -> None:
self._store[key] = CacheEntry(value=value, expires_at=time.time() + ttl_s)
cache = TtlCache()
def make_key(tenant_id: str, tool: str, args: dict) -> str:
normalized = json.dumps(args, sort_keys=True, separators=(",", ":"))
raw = f"{tenant_id}|{tool}|{normalized}"
return hashlib.sha256(raw.encode()).hexdigest()
def cached_tool(
tenant_id: str,
tool: str,
args: dict,
fn: Callable[[], Any],
ttl_s: float,
) -> dict:
key = make_key(tenant_id, tool, args)
hit = cache.get(key)
if hit is not None:
return {"ok": True, "data": hit, "source": "cache"}
data = fn()
cache.set(key, data, ttl_s=ttl_s)
return {"ok": True, "data": data, "source": "live"}
def kb_fetch(doc_id: str) -> dict:
# stand-in for network fetch
return {"doc_id": doc_id, "body": "password reset steps..."}
print(cached_tool("tenant-a", "kb.fetch", {"doc_id": "faq-1"}, lambda: kb_fetch("faq-1"), ttl_s=60))
print(cached_tool("tenant-a", "kb.fetch", {"doc_id": "faq-1"}, lambda: kb_fetch("faq-1"), ttl_s=60))| Target | Why | Risk |
|---|---|---|
| KB / doc fetch | High repeat, stable | Stale policy docs if TTL too long |
| Search snippets | Expensive RPM | Stale web results |
| Schema/catalog reads | Hot paths | Version drift if not keyed |
| Idempotent compute tools | CPU savings | Wrong if inputs not normalized |
| Final natural-language answers | Saves full loops | Personalization and tone bugs |
| Writes / payments / emails | - | Almost never |
Tool-result caches often beat end-to-end answer caches on both safety and hit rate.
tenant_id always; include user_id when data is personal.Cache final answers only when:
Semantic cache (embed query → nearest prior answer) can raise hit rate but adds wrong-answer risk. Start with exact keys; add semantic only with similarity thresholds and eval.
Some model providers offer prompt/prefix caching for repeated system prompts.
That reduces token pricing for large stable prefixes. It does not replace:
Use both layers when available (verify provider behavior and pricing at build).
Strategies:
content_version in keyStale cache is a correctness bug, not only a scale footgun.
In-process dicts do not share hits across workers.
Use Redis (or similar) for fleet hit rate. For multi-region, decide whether cross-region cache sharing is worth latency and consistency complexity.
Log:
cache_hit / cache_missA hit-rate cliff often means a deploy changed normalization or traffic shifted to novel goals.
"Q:" vs "q:" destroys hit rate.source in traces. You cannot debug freshness.| Approach | Strength | Weakness |
|---|---|---|
| App-level TTL cache (this recipe) | Full control, tool-aware | You own invalidation |
| CDN/HTTP cache on public GETs | Great for static | Weak for authenticated tool graphs |
| Provider prompt caching | Cheaper large prefixes | Still pays a generation often |
| No cache, only cheaper models | Simpler | Leaves free wins on the table |
| Precompute batch FAQ store | Highest quality control | Ops to keep content updated |
Combine caching with Cost-Aware Scaling: Routing Load to Cheaper Models Under Pressure for spike survival.
No. Only deterministic or acceptably stale reads. Never default-cache mutating tools.
In tool wrappers or a shared repository layer nodes call (verify patterns at build). Not as a prompt instruction alone.
Match business freshness: seconds for markets, minutes for inventory, hours/days for docs. Prefer explicit product rules.
It can. Key by user or skip cache when the answer must include private context.
Highly dependent. Support FAQs may exceed 50%; open-ended coding agents may stay under 10% on finals but still win on tool reads.
Yes for repeated chunk texts. Key by content hash and model/version of the embedding model.
Single-flight locks or probabilistic early refresh so one worker rebuilds while others wait.
No for multi-user backends. Clients help UX; servers protect spend and quotas.
Cache stores are data stores. Apply the same retention, encryption, and residency rules as primary DBs.
Hits never take model or tool slots, so effective capacity rises without raising 429 risk. See Concurrency Limits and Backpressure for Agent Workers.
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.
Reviewed by Chris St. John·Last updated Jul 16, 2026