← Back to DevBytes

How to Cache Tool Outputs for Cost-Effective Agents

How to Cache Tool Outputs for Cost-Effective Agents

AI agents that call external tools—search APIs, database queries, code interpreters, web scrapers—can quickly rack up costs and latency. Many of those tool calls return identical or near-identical results across runs, especially during development, evaluation, and iterative prompting. Caching tool outputs is one of the highest-leverage optimizations available: it reduces token spend, shortens response times, and makes agents more predictable. This tutorial walks through what tool output caching is, why it matters, how to implement it, and the best practices that separate a toy cache from a production-grade one.

What Is Tool Output Caching?

Tool output caching is the practice of storing the results of tool calls keyed by their inputs, so that when an agent requests the same operation again, the cached result is returned instead of re-executing the tool. Conceptually, it is a memoization layer sitting between the agent's reasoning loop and the actual tool implementation.

A cache entry typically contains four things:

When the agent decides to call a tool, the runtime first checks the cache. On a hit, it injects the stored result back into the conversation as if the tool had just run. On a miss, it executes the tool, stores the result, and then proceeds.

Why It Matters

The economics of agentic systems are dominated by three factors: token cost, latency, and API rate limits. Caching attacks all three:

In practice, teams report 40–80% reductions in tool-related costs after introducing caching, with the biggest wins in research agents, retrieval-augmented generation pipelines, and any agent that re-runs the same workflow multiple times.

How to Use It: A Practical Implementation

Let's build a minimal but realistic caching layer in Python. We'll start with an in-memory cache, then extend it to disk and Redis. The design goal is to make caching transparent: tools should not need to know caching exists.

Step 1: Define the Cache Interface

import hashlib
import json
import time
from abc import ABC, abstractmethod
from typing import Any, Optional


class ToolCache(ABC):
    @abstractmethod
    def get(self, key: str) -> Optional[Any]:
        ...

    @abstractmethod
    def set(self, key: str, value: Any, ttl: int) -> None:
        ...

    @abstractmethod
    def delete(self, key: str) -> None:
        ...

    @staticmethod
    def make_key(tool_name: str, args: dict, kwargs: dict) -> str:
        payload = json.dumps(
            {"tool": tool_name, "args": args, "kwargs": kwargs},
            sort_keys=True,
            default=str,
        )
        return hashlib.sha256(payload.encode()).hexdigest()

The make_key method is critical. It serializes the tool name and arguments in a canonical, sorted form so that {"q": "weather", "units": "metric"} and {"units": "metric", "q": "weather"} produce the same key.

Step 2: Implement an In-Memory Cache

class InMemoryCache(ToolCache):
    def __init__(self):
        self._store: dict[str, tuple[Any, float]] = {}

    def get(self, key: str) -> Optional[Any]:
        entry = self._store.get(key)
        if entry is None:
            return None
        value, expires_at = entry
        if expires_at and time.time() > expires_at:
            del self._store[key]
            return None
        return value

    def set(self, key: str, value: Any, ttl: int) -> None:
        expires_at = time.time() + ttl if ttl > 0 else 0
        self._store[key] = (value, expires_at)

    def delete(self, key: str) -> None:
        self._store.pop(key, None)

Step 3: Wrap Tools with a Caching Decorator

import functools

def cached_tool(cache: ToolCache, ttl: int = 3600):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            key = ToolCache.make_key(func.__name__, args, kwargs)
            cached = cache.get(key)
            if cached is not None:
                print(f"[cache hit] {func.__name__}")
                return cached
            print(f"[cache miss] {func.__name__}")
            result = func(*args, **kwargs)
            cache.set(key, result, ttl)
            return result
        return wrapper
    return decorator

Step 4: Apply It to a Real Tool

import requests

cache = InMemoryCache()

@cached_tool(cache, ttl=1800)
def web_search(query: str, max_results: int = 5) -> list[dict]:
    response = requests.get(
        "https://api.example-search.com/v1/search",
        params={"q": query, "limit": max_results},
        timeout=10,
    )
    response.raise_for_status()
    return response.json()["results"]

# First call: executes the HTTP request
results_a = web_search("python asyncio tutorial")

# Second call: returns instantly from cache
results_b = web_search("python asyncio tutorial")

assert results_a is results_b  # same object, zero network cost

Step 5: Integrate with an Agent Loop

Most agent frameworks expose a hook where tool calls are dispatched. You can intercept that hook to check the cache before execution. Here is a simplified agent loop that demonstrates the pattern:

class CachingAgent:
    def __init__(self, model, tools: dict, cache: ToolCache, ttl: int = 3600):
        self.model = model
        self.tools = tools
        self.cache = cache
        self.ttl = ttl

    def run(self, user_input: str, max_steps: int = 10) -> str:
        messages = [{"role": "user", "content": user_input}]
        for _ in range(max_steps):
            response = self.model.chat(messages, tools=list(self.tools))
            messages.append(response.message)
            if not response.tool_calls:
                return response.message["content"]
            for call in response.tool_calls:
                result = self._execute_tool(call)
                messages.append({
                    "role": "tool",
                    "tool_call_id": call.id,
                    "content": json.dumps(result),
                })
        return "Max steps reached."

    def _execute_tool(self, call) -> Any:
        tool = self.tools[call.name]
        key = ToolCache.make_key(call.name, call.arguments, {})
        cached = self.cache.get(key)
        if cached is not None:
            return cached
        result = tool(**call.arguments)
        self.cache.set(key, result, self.ttl)
        return result

Step 6: Scale with Redis

In-memory caches do not survive restarts and cannot be shared across processes. For production, use Redis:

import pickle
import redis

class RedisCache(ToolCache):
    def __init__(self, host="localhost", port=6379, db=0, prefix="tool:"):
        self.client = redis.Redis(host=host, port=port, db=db)
        self.prefix = prefix

    def get(self, key: str) -> Optional[Any]:
        raw = self.client.get(self.prefix + key)
        if raw is None:
            return None
        return pickle.loads(raw)

    def set(self, key: str, value: Any, ttl: int) -> None:
        raw = pickle.dumps(value)
        if ttl > 0:
            self.client.setex(self.prefix + key, ttl, raw)
        else:
            self.client.set(self.prefix + key, raw)

    def delete(self, key: str) -> None:
        self.client.delete(self.prefix + key)

Swap InMemoryCache for RedisCache and nothing else in your agent code changes. That is the value of the abstraction.

Best Practices

Choose TTLs Based on Data Volatility

Not all tools deserve the same TTL. Stock prices change every second; a Wikipedia article's content is stable for days. Set per-tool TTLs:

Normalize Keys Aggressively

Small variations in arguments cause unnecessary cache misses. Normalize before hashing:

def normalize_search_args(args: dict) -> dict:
    normalized = dict(args)
    if "query" in normalized:
        normalized["query"] = normalized["query"].strip().lower()
    if "max_results" in normalized:
        normalized["max_results"] = int(normalized["max_results"])
    # Drop volatile fields that should not affect the key
    normalized.pop("request_id", None)
    return normalized

Never Cache Non-Deterministic Tools Blindly

Tools that depend on time, random numbers, user-specific state, or mutable external systems are dangerous to cache. Either exclude them, or include the volatile dimension in the key. For example, a "get current weather" tool should include the timestamp rounded to the nearest five minutes in its key, not just the city name.

Bound Cache Size

Unbounded caches cause memory leaks and Redis bloat. Use an LRU eviction policy in Redis, or implement a max-size check in your in-memory cache:

from collections import OrderedDict

class LRUCache(ToolCache):
    def __init__(self, max_size: int = 1000):
        self.max_size = max_size
        self._store: OrderedDict[str, tuple[Any, float]] = OrderedDict()

    def get(self, key: str) -> Optional[Any]:
        entry = self._store.get(key)
        if entry is None:
            return None
        value, expires_at = entry
        if expires_at and time.time() > expires_at:
            del self._store[key]
            return None
        self._store.move_to_end(key)
        return value

    def set(self, key: str, value: Any, ttl: int) -> None:
        expires_at = time.time() + ttl if ttl > 0 else 0
        self._store[key] = (value, expires_at)
        self._store.move_to_end(key)
        while len(self._store) > self.max_size:
            self._store.popitem(last=False)

    def delete(self, key: str) -> None:
        self._store.pop(key, None)

Log Cache Hits and Misses

Instrument your cache so you can measure hit rate, latency savings, and cost avoidance. A simple counter-based approach:

class InstrumentedCache(ToolCache):
    def __init__(self, inner: ToolCache):
        self.inner = inner
        self.hits = 0
        self.misses = 0

    def get(self, key: str) -> Optional[Any]:
        value = self.inner.get(key)
        if value is not None:
            self.hits += 1
        else:
            self.misses += 1
        return value

    def set(self, key: str, value: Any, ttl: int) -> None:
        self.inner.set(key, value, ttl)

    def delete(self, key: str) -> None:
        self.inner.delete(key)

    @property
    def hit_rate(self) -> float:
        total = self.hits + self.misses
        return self.hits / total if total else 0.0

Provide Manual Invalidation

Sometimes you know a tool's underlying data has changed—a document was updated, a database row was modified. Expose an invalidation API so callers can evict specific entries:

def invalidate_tool(cache: ToolCache, tool_name: str, **kwargs):
    key = ToolCache.make_key(tool_name, (), kwargs)
    cache.delete(key)

Consider Semantic Caching for Fuzzy Matches

Exact-match caching misses near-duplicates like "What is the capital of France?" versus "What's France's capital?" Semantic caching uses embeddings to find similar past queries and reuse their results. This is more complex and introduces a similarity threshold you must tune carefully to avoid returning stale or incorrect results, but for high-volume retrieval agents it can double your effective hit rate.

Cache at the Right Granularity

Caching entire tool responses is the simplest approach, but sometimes you should cache sub-results. If a search tool returns ten results and the agent only uses three, consider caching each result individually so partial overlaps across queries can be reused. The trade-off is complexity: finer granularity means more cache entries and more key management.

Handle Serialization Safely

Tool outputs must be serializable to survive disk or Redis storage. Prefer JSON-serializable return types. If you must return complex objects, use pickle with awareness of its security implications—never unpickle data from untrusted sources. Document the expected return type of every cached tool so downstream consumers know what they are getting.

Test Cache Behavior Explicitly

Add tests that verify cache hits return identical results, TTLs expire correctly, and invalidation works. A common bug is a tool whose output contains a timestamp or request ID that changes on every call, defeating the cache entirely. Tests catch this early.

def test_cache_returns_same_result():
    cache = InMemoryCache()

    @cached_tool(cache, ttl=60)
    def add(a, b):
        return a + b

    first = add(2, 3)
    second = add(2, 3)
    assert first == second
    assert cache._store  # entry was stored

def test_cache_expires():
    cache = InMemoryCache()

    @cached_tool(cache, ttl=1)
    def echo(x):
        return x

    echo("hello")
    time.sleep(2)
    assert cache.get(ToolCache.make_key("echo", ("hello",), {})) is None

Conclusion

Caching tool outputs is a straightforward technique with outsized impact on agent cost, latency, and reliability. By inserting a thin memoization layer between your agent's reasoning loop and its tools, you eliminate redundant work, smooth out API rate limits, and gain reproducibility for evaluation. The implementation does not need to be elaborate—a decorator, a key-derivation function, and a TTL-aware store are enough to start. As your agents scale, graduate to Redis-backed caches with per-tool TTLs, instrumentation, and explicit invalidation. Treat caching as a first-class concern in your agent architecture, not an afterthought, and you will see immediate returns in both performance and operating cost.

— Ad —

Google AdSense will appear here after approval

← Back to all articles