Course Content
Live Coding Interview Prep
7 sections · 50 lessons
Build a caching layer for LLM responses.
What you need to know
The same prompt is sent far more often than you would expect: FAQ questions, nightly batch jobs re-run on unchanged rows, retries after a timeout. An exact-match cache returns the stored answer in about a millisecond, for nothing.
The key must include every input that changes the output. Forget temperature in the key and a creative-writing request at 1.0 gets served the answer generated at 0.0. Hash a canonical JSON of everything:
sort_keys=Trueso{"a":1,"b":2}and{"b":2,"a":1}hash the same;- compact separators so whitespace changes do not matter.
LRU (least recently used) eviction drops the entry that has gone longest without a read. An OrderedDict keeps insertion order and can move a key to the end in O(1), which makes it a ready-made LRU.
TTL (time to live) expires entries after a fixed time, so a changed policy document does not keep producing old answers forever.
Response cache (this question)
- Your code stores the whole answer
- Hit: no model call at all, near-zero cost
- Only for identical requests
Provider prompt caching
- The provider stores the processed prompt prefix
- Hit: cheaper, faster input; the answer is still generated
- Works for any request sharing a long prefix
1import hashlib, json, time2from collections import OrderedDict3from collections.abc import Callable45def cache_key(model: str, messages: list[dict], **params) -> str:6 """Stable hash of every input that affects the output."""7 payload = json.dumps({"model": model, "messages": messages, "params": params},8 sort_keys=True, separators=(",", ":"), ensure_ascii=False)9 return hashlib.sha256(payload.encode("utf-8")).hexdigest()1011_MISS = object()1213class TTLCache:14 """LRU cache with per-entry expiry. get/set are O(1)."""1516 def __init__(self, maxsize: int = 1000, ttl: float = 3600,17 clock: Callable[[], float] = time.monotonic) -> None:18 self.maxsize, self.ttl, self.clock = maxsize, ttl, clock19 self.store: OrderedDict[str, tuple[float, object]] = OrderedDict()20 self.hits = self.misses = 02122 def get(self, key: str):23 entry = self.store.get(key)24 if entry is None or entry[0] <= self.clock():25 self.store.pop(key, None) # drop it if it had expired26 self.misses += 127 return _MISS28 self.store.move_to_end(key) # now the most recently used29 self.hits += 130 return entry[1]3132 def set(self, key: str, value) -> None:33 self.store[key] = (self.clock() + self.ttl, value)34 self.store.move_to_end(key)35 if len(self.store) > self.maxsize:36 self.store.popitem(last=False) # evict the least recently used3738def cached_call(model: str, messages: list[dict], call_fn: Callable, cache: TTLCache, **params):39 key = cache_key(model, messages, **params)40 if (hit := cache.get(key)) is not _MISS:41 return hit42 value = call_fn(model=model, messages=messages, **params)43 cache.set(key, value)44 return valueThe tricky parts:
_MISSsentinel. ReturningNonefor a miss makes it impossible to cache a legitimateNoneor empty result; a private sentinel object cannot collide with real data.time.monotonicfor expiry, injectable asclockso tests can move time forward.ensure_ascii=Falsekeeps Hindi or Tamil text as UTF-8 in the key payload; the hash is the same either way, but the payload is readable when debugging.
Complexity: cache_key is O(size of the request) to serialise and hash. get and set are O(1): dict lookup, move_to_end and popitem(last=False) are constant time in an OrderedDict. Space is O(maxsize × response size).
A real-life example
A fake clock and a counting model show hits, a parameter change, LRU eviction and expiry:
1now = [0.0]2cache = TTLCache(maxsize=2, ttl=60, clock=lambda: now[0])3calls = []4def model_call(model, messages, **params):5 calls.append(messages[-1]["content"])6 return f"answer to {messages[-1]['content']}"78ask = lambda q, **p: cached_call("claude-opus-5", [{"role": "user", "content": q}],9 model_call, cache, **p)10ask("refund time?", temperature=0) # miss -> model11ask("refund time?", temperature=0) # hit12ask("refund time?", temperature=1) # different key -> model; cache now full (2)13ask("cod available?", temperature=0) # model; evicts the least recently used entry14now[0] = 61.015ask("cod available?", temperature=0) # expired -> model again16print(len(calls), cache.hits, cache.misses) # 4 1 4| call | key | cache state after (oldest first) | result |
|---|---|---|---|
| 1 | refund, t=0 | [refund/0] | miss |
| 2 | refund, t=0 | [refund/0] | hit |
| 3 | refund, t=1 | [refund/0, refund/1] | miss |
| 4 | cod, t=0 | [refund/1, cod/0] — refund/0 evicted | miss |
| 5 (t = 61 s) | cod, t=0 | [refund/1] — cod/0 expired and dropped | miss |
A railway enquiry bot answering "what is the Tatkal booking time?" thousands of times a day serves almost all of them from this cache.
Follow-up questions to expect
- "Hit rate is zero in production — why?" — Something unique is in the key: a timestamp or request id in the system prompt, or unsorted JSON. Log the key payload for two identical-looking requests and diff them.
- "Can you cache per-user answers?" — Only with the user or tenant id inside the key. Otherwise one customer's order details are served to another.
- "A popular key expires and 500 requests miss at once — what happens?" — A cache stampede. Use a per-key lock (single-flight) so one request refills the cache while the others wait for it.