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.


- Normalize before you key
- TTL by intent
- The cache
- Wiring it to the tool
- Cache the reads too
- When to skip the cache entirely
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.