Caching Search Results Without Making Your Agent Stale

The whole point of a search tool is freshness, so caching feels like cheating. It isn't — if you pick TTLs by query intent instead of by convenience.

Profile picture of Serply
Serply
A cache layer between an agent and a search API with varying TTLs

There’s a reflex against caching search results in agent systems: you added live search precisely because the model’s knowledge was stale, so caching reintroduces the problem you were solving.

That reasoning is right about some queries and badly wrong about most. “What is the current bitcoin price” needs a TTL measured in seconds. “What HTTP header does the Stripe API use for auth” is stable for years. Treating those identically — either by never caching or by using one global TTL — is the actual mistake.

Normalize before you key

Cache keys built from raw query strings miss constantly. Agents generate near-duplicate queries by nature:

import hashlib
import re


def cache_key(query: str, num: int, proxy: str) -> str:
    normalized = re.sub(r"\s+", " ", query.strip().lower())
    normalized = re.sub(r'^["\'](.*)["\']$', r"\1", normalized)
    raw = f"{normalized}|{num}|{proxy}"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]

Including proxy in the key is not optional. Serply’s X-Proxy-Location header changes what Google returns — a US exit node and a German one produce genuinely different result sets — so a cache that ignores it will serve one region’s results to another’s query. Same for num: a cached 10-result response can’t satisfy a request for 50.

Don’t normalize away quotes inside a query, though. apple "trade in" and apple trade in are different searches, and collapsing them will quietly degrade results.

TTL by intent

The useful move is classifying the query and picking a TTL from that:

TTLS = {
    "realtime": 30,        # prices, scores, "right now"
    "news": 300,           # current events, announcements
    "commerce": 1800,      # product listings, availability
    "general": 21600,      # how things work, comparisons
    "reference": 604800,   # docs, specs, definitions
}

REALTIME = re.compile(r"\b(price|stock|score|live|right now|currently|today)\b", re.I)
NEWS = re.compile(r"\b(news|announce|launch|release|breaking|report|acquisition)\b", re.I)
COMMERCE = re.compile(r"\b(buy|cheap|deal|shipping|in stock|discount|for sale)\b", re.I)
REFERENCE = re.compile(r"\b(documentation|docs|api|spec|syntax|definition|how does)\b", re.I)


def classify(query: str) -> str:
    if REALTIME.search(query):
        return "realtime"
    if NEWS.search(query):
        return "news"
    if COMMERCE.search(query):
        return "commerce"
    if REFERENCE.search(query):
        return "reference"
    return "general"

Regexes are crude and that’s fine — this is a cost decision, not a correctness one. The failure mode of misclassifying a reference query as general is a slightly lower hit rate, not a wrong answer. If you want better, have the agent pass an intent hint as a tool parameter; models are quite good at labeling their own query’s volatility when you ask.

The cache

import json
import time
from typing import Any


class SearchCache:
    def __init__(self, backend):
        self.backend = backend  # anything with get/setex, e.g. redis

    def get_or_fetch(self, query: str, num: int, proxy: str, fetch) -> tuple[Any, bool]:
        key = f"serp:{cache_key(query, num, proxy)}"
        hit = self.backend.get(key)
        if hit:
            return json.loads(hit), True

        value = fetch()
        ttl = TTLS[classify(query)]
        self.backend.setex(key, ttl, json.dumps(value))
        return value, False

Returning the hit/miss flag lets you surface it in traces. Cache hit rate is the metric that tells you whether your agent’s query generation is stable or whether it’s rephrasing the same question a dozen ways — which is itself worth knowing.

Wiring it to the tool

import os
import requests
from urllib.parse import quote_plus

API_KEY = os.environ["SERPLY_API_KEY"]
cache = SearchCache(redis_client)


def web_search(query: str, num: int = 10, proxy: str = "US") -> str:
    def fetch():
        resp = requests.get(
            f"https://api.serply.io/v1/search/q={quote_plus(query)}&num={num}",
            headers={"X-Api-Key": API_KEY, "X-Proxy-Location": proxy},
            timeout=30,
        )
        if resp.status_code == 429:
            return {"error": "rate_limited"}
        resp.raise_for_status()
        return resp.json()

    data, was_hit = cache.get_or_fetch(query, num, proxy, fetch)

    if data.get("error") == "rate_limited":
        return "Rate limited. Answer with available information; do not retry."

    results = data.get("results", [])
    return "\n\n".join(
        f"[{r.get('position')}] {r.get('title')}\n{r.get('link')}\n{r.get('description', '')}"
        for r in results
    ) or f"No results for '{query}'."

One bug to avoid: don’t cache the error. The snippet above stores {"error": "rate_limited"} under the query’s TTL, which means a transient rate limit poisons that query for up to a week if it happened to be classified as reference. Check for the error before writing:

        value = fetch()
        if isinstance(value, dict) and value.get("error"):
            return value, False        # return it, don't store it
        self.backend.setex(key, ttl, json.dumps(value))

Cache the reads too

Page fetches through /v1/request are slower and more expensive than searches, and pages change less often than result rankings:

def read_page(url: str) -> str:
    key = f"page:{hashlib.sha256(url.encode()).hexdigest()[:32]}"
    hit = redis_client.get(key)
    if hit:
        return hit.decode()

    resp = requests.post(
        "https://api.serply.io/v1/request",
        headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
        json={"url": url, "response_type": "markdown"},
        timeout=60,
    )
    resp.raise_for_status()
    text = resp.text
    redis_client.setex(key, 3600, text)
    return text

An hour is a defensible default for article and documentation pages. Drop it hard for anything with a price on it — a cached pricing page is worse than no pricing page, because it’s confidently wrong.

When to skip the cache entirely

Give the tool a bypass and let the agent use it:

def web_search(query: str, num: int = 10, fresh: bool = False) -> str:
    """...
    Args:
        fresh: Set true only when you specifically need up-to-the-minute data
               and a few minutes of staleness would change the answer.
    """

Described that way, models use it sparingly and appropriately — reaching for it on “current price” and leaving it alone on “how does OAuth work.” A parameter described as “skip cache” gets set to true on everything.