A nightly agent job runs 40 steps. Last sprint someone added a response cache to save calls on a free model endpoint. Tonight the summary cites a documentation paragraph that was deleted six hours ago, and nobody notices until a human reads the output.
Caching is the cheapest optimization in an agent pipeline and the easiest way to ship stale facts. The problem is not "should we cache" — it is which step deserves a cache, and with what invalidation contract. This post gives you a glossary, a four-question decision tree, and a worked example at each leaf, plus a replay test you can run before trusting any of it.
Glossary: seven terms worth agreeing on first
- Step — one model call plus its deterministic wrapper (prompt assembly, parsing, validation). Caching decisions belong to the step, not the pipeline.
- Cache key — the exact bytes that identify a reusable result. If the key omits an input, you get a silent wrong answer, not a miss.
- Normalization — the rewrite applied to a prompt before hashing (whitespace collapse, Unicode NFKC, lowercasing). It raises hit rate and raises collision risk in the same move.
-
Staleness class — a label for how bad a stale answer is:
cosmetic,misleading, orunsafe. Onlycosmeticis a safe default for TTL caching. - TTL — the wall-clock bound on reuse. A TTL is a promise you make to readers about how old your facts can be.
- Input digest — a hash of the files or records the step reads, not just the prompt text. Prompt-only keys miss content changes.
- Hit budget — the fraction of steps you expect to serve from cache. Write it down; an unmeasured cache is a guess with extra failure modes.
The decision tree
Ask four questions per step, in order. Stop at the first leaf.
Q1. Does the step read external mutable state (live DB, clock, web, file tree)?
yes -> Q2
no -> LEAF A (exact memo, no TTL)
Q2. Is a stale answer ever unsafe (side effects, money, access, published text)?
yes -> LEAF C (no cache)
no -> Q3
Q3. Do prompts vary only by whitespace/casing/Unicode form across runs?
yes -> LEAF D (normalized key + namespace)
no -> LEAF B (TTL cache, TTL from staleness class)
Four leaves. Each one below is a runnable decision, not a vibe.
Leaf A — exact memo, no TTL
Pure steps: schema validation of a tool call, prompt linting, deterministic reformatting, unit conversion. The output cannot change unless the input changes, so a TTL is pointless — the input digest is the invalidation.
from dataclasses import dataclass
from hashlib import sha256
import json
@dataclass(frozen=True)
class Step:
name: str
prompt: str
input_digest: str # hash of files/records the step reads
reads_mutable_state: bool
stale_is_unsafe: bool
normalizable: bool = False
def cache_key(step: Step, model_tag: str) -> str:
payload = {
"model": model_tag,
"prompt": step.prompt,
"input": step.input_digest,
}
blob = json.dumps(payload, sort_keys=True).encode("utf-8")
return sha256(blob).hexdigest()[:16]
Note model_tag is in the key. Swapping the model behind the same prompt must not reuse the old answer — that is the single most common cache bug in free-tier pipelines.
Leaf B — TTL cache, TTL set by staleness class
Steps that read mutable state but tolerate staleness: doc-snippet retrieval, changelog summaries, format detection. Here the cache lives for a bounded time and the bound is chosen from the staleness class, not from "how much money would we save".
This is where placement matters. A cache needs to survive between runs but not between deploys, and it needs to be reachable from wherever the step executes. Running the cache on the same box as the worker removes a network hop and one credential.
MonkeyCode offers free model access and a free server option (both operator-supplied availability claims — confirm current limits on the project page), which makes Leaf B practical for a hobby-scale job: model calls on the free endpoint, cache and state on the free server.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
import time
TTL_BY_CLASS = {"cosmetic": 6 * 3600, "misleading": 15 * 60}
def should_reuse(step: Step, cached_at: float, staleness_class: str) -> bool:
ttl = TTL_BY_CLASS.get(staleness_class)
if ttl is None:
return False # 'unsafe' has no TTL — see Leaf C
return (time.time() - cached_at) < ttl
One rule: store the staleness class next to the value, not in your head. When the class changes after a code review, you want the old entries refused automatically.
Leaf C — no cache at all
Anything with a side effect or an accuracy obligation: sending mail, writing to a repo, granting access, producing the final user-facing answer. Recompute. If a free-tier call budget forces the issue, shrink the step instead of caching it — cache the retrieval that feeds the step, not the conclusion.
def admit(step: Step) -> str:
if not step.reads_mutable_state:
return "leaf_a_exact_memo"
if step.stale_is_unsafe:
return "leaf_c_no_cache"
if step.normalizable:
return "leaf_d_normalized"
return "leaf_b_ttl"
That function is the whole artifact. Pipe every step definition through it in a test and assert the mapping matches your written design; the test is what stops someone from adding a cache to the final-answer step during a Friday refactor.
Leaf D — normalized key with a namespace
Prompts that differ only in whitespace or Unicode form deserve a normalized key — but the namespace must record the normalization version, or an edit to your normalizer silently reuses entries built under the old rules.
import re, unicodedata
def normalize(text: str, version: str = "v1") -> str:
t = unicodedata.normalize("NFKC", text).strip().lower()
t = re.sub(r"\s+", " ", t)
return f"{version}:{t}"
Test the collision behavior explicitly:
assert normalize("Summarize this.") == normalize("summarize this.\n")
assert normalize("a") != normalize("a", version="v2")
If the second assertion ever fails, every cached entry is now ambiguous across normalizer versions.
The replay test: three checks before you enable anything
- Hit-rate check. Replay recorded step inputs with the cache on. Record actual hits; compare to the hit budget you wrote down. A hit rate far above budget usually means your key is missing an input.
- Invalidation check. Mutate the source document that feeds one Leaf-B step. The next run must miss. If it hits, your TTL is carrying all the safety and your digest is decorative.
- Kill-switch check. Flip an environment flag that disables the cache and confirm the pipeline still produces identical output. A cache you cannot turn off in one move is an outage waiting for a bad deploy.
Decision table
| Step | Mutable state | Stale unsafe | Leaf | Invalidation |
|---|---|---|---|---|
| Tool-call schema validation | no | n/a | A | input digest |
| Doc snippet retrieval | yes | no | B | TTL + digest |
| Final user answer | yes | yes | C | none (recompute) |
| Intent classification | yes | no | D | TTL + norm version |
Limitations and who should not use this
- Latency-sensitive paths. If a step runs once per request and the cache lives on a separate free server, the round trip can cost more than the recompute. Co-locate or skip.
- Steps with hidden inputs. If your prompt assembly pulls in conversation history or a user profile, the digest must include it. Steps you cannot fully describe should stay at Leaf C.
- Workloads with no repetition. Low-overlap traffic produces misses plus a cache to maintain. Measure first; if your replay shows a single-digit hit rate, delete the layer.
- Anything with a compliance clock. If readers are entitled to fresh data, a TTL cache is a policy change, not a performance tweak. Get it reviewed as one.
The cache is not the interesting part. The interesting part is the sentence you can write afterwards: this step may serve a result up to N minutes old, and here is the test that proves it. Steps that cannot survive that sentence belong at Leaf C.
If you want to try the shape — free model calls plus a free server to host the cache and the replay harness — MonkeyCode's project page is the place to check current terms, then run the three checks above against your own recorded steps before enabling anything in production.
Top comments (2)
The decision-tree framing is right, and the sharpest part of it is the replay test at the end — it is the only check that catches the failure mode that matters most. A cache can pass the hit-rate check comfortably and still be a net loss: if it fails the invalidation check, every hit it reports is a stale answer booked as a saving, and a high hit rate just means serving the wrong answer faster.
Two details I'd add from running that test on trajectory-shaped work. First, the invalidation check has to mutate the thing the step actually read, not just the prompt text: a read step whose source changed with no taint recorded should be a miss, not a hit, and a write step should only ever replay on an exact match — writes never fuzzy-match, and an abstract match no longer counts as an exact repeat for a write. On an 18-pair set of near-opposite commands (activate vs. deactivate style pairs), a threshold-only matcher served the wrong trajectory once; a gate that refuses fuzzy matches on writes held at zero false positives. Second, the kill-switch check is where the stale-read problem shows up: a replayed read is a claim about a past state, and a response that doesn't say how old it is hides exactly the fact the caller needs. Storing the observation time next to the step is the cheap version — the caller decides. Verification at replay is the honest version, and it costs a live call, which is the thing the cache was meant to save. That tradeoff, along with a permission/state precondition check and boundary marking on read matches, is still open, not solved — so I'd treat passing all three replay checks as necessary but not sufficient, and still write down the sentence this post ends on: this step may serve a result up to N minutes old, and here is the test that proves it.
The step-admission framing is the right unit — a cache key that misses an input doesn't produce a miss, it produces a silent wrong answer, and that's the failure mode that matters. Two additions from the same discipline: first, the replay test is the gate, not the garnish — hit-rate vs budget, invalidation on source mutation, kill-switch with output identical with the cache off; if any of those three fails, the cache isn't ready. Second, the staleness classes deserve a fourth: a replayed read is a claim about a past state, and a response that doesn't say how old it is hides exactly the fact the caller needs — store the observation time next to the step at minimum, verify at replay where the stakes justify it. On the write side the rule we hold is absolute: write steps replay only on an exact goal-plus-context repeat, never on similarity, and a hit never serves past a declared external write — 0 wrong hits on an 18-pair adversarial set is the bar we measure against, not a benchmark average.