Cache an expensive multi-step LLM pipeline with two read layers plus a write-side accumulation library. Layer 1 is an exact hash match on normalized input — instant, free, deterministic. Layer 2 is a semantic match: embed the input, compare by cosine similarity above a tuned threshold, guarded by a categorical check. The pipeline degrades gracefully to no-cache behind feature flags.

That summary hides the part that actually matters in production: where each layer earns its keep, and where semantic caching quietly corrupts your output if you tune it on the wrong data. We built this pattern for an LLM pipeline that turns a free-text contextual input into structured output through several chained model calls, and we will walk through the architecture without the client-specific parts.

Why cache a multi-step LLM pipeline at all?

A pipeline that chains several LLM calls to produce one structured output is expensive twice over: in token cost and in wall-clock latency. Each step waits on the previous one, so a four-step chain compounds both. Caching the assembled final output against the first step's input collapses all of that into a single key lookup on repeat requests.

The economics are stark. A chained generation that costs real money and several seconds per run becomes near-zero cost and sub-100ms on a cache hit. When the same kind of request recurs across users — and in most real pipelines it does — the cache stops being an optimization and becomes the dominant cost lever.

We treat the cache key as the normalized output of the first pipeline step, not the raw user input. Normalization (lowercasing, whitespace collapse, punctuation stripping) means trivially different phrasings of the same request already collide at the cheapest possible layer. This is the same instinct behind how we cut Claude API costs by 90% with prompt caching in production — except here we are caching the assembled result of an entire chain, not a model-level prefix.

Why does exact-match caching alone barely work for natural language?

Exact-match caching alone is nearly useless for natural-language inputs because users phrase the same request a hundred different ways. Normalization catches casing and whitespace, but it cannot see that "trouble sleeping at night" and "can't fall asleep" are the same intent. So the L1 hit rate on free text stays disappointingly low no matter how clean your normalization is.

This is the single most common mistake we see teams make: they ship an exact-match cache, watch the hit rate sit at 10%, and conclude caching doesn't work for their workload. The cache works fine. The problem is that natural language has near-infinite surface forms for the same underlying meaning, and a hash is blind to all of them.

The fix is not to abandon exact-match — it is the fastest and safest layer, so it stays as L1. The fix is to layer a second mechanism on top that matches on meaning rather than bytes.

How does the L1 exact-match layer work?

L1 is a deterministic hash lookup. Normalize the first step's input text, take a SHA-256 hash of the normalized string, and do a direct key lookup in the database. On a hit, you return the stored assembled output immediately — zero model calls, zero embedding cost, single-digit milliseconds. On a miss, you fall through to L2.

The value of L1 is that it is impossible to get wrong. A SHA-256 collision on real text does not happen, so an L1 hit is always a true hit. There is no threshold to tune and no false-merge risk. It is the layer you want to absorb as much traffic as it can before you spend money on anything smarter.

We keep L1 cheap and boring on purpose. All the interesting failure modes live in L2.

How does the L2 semantic layer match on meaning?

On an L1 miss, L2 embeds the normalized input using an embedding model — we used OpenAI's text-embedding-3-large — and compares that vector against stored embeddings by cosine similarity. If the best match exceeds a tuned threshold and a categorical guard matches, we reuse the cached result. Otherwise the pipeline runs for real.

The categorical guard is the part most semantic caches skip, and it is what makes the difference between a cache that helps and one that silently corrupts output. We require the stored row's category to equal the query's category before a semantic merge is allowed. Two inputs can sit just above the similarity threshold while belonging to genuinely different buckets — the category check is cheap insurance against exactly those near-threshold mistakes.

One detail that pays off later: we store the embedding on every write, not only when L2 is enabled. That means L2 becomes available retroactively. You can ship the write path first, accumulate embeddings silently for weeks, then flip the read flag on a corpus that already has full semantic coverage instead of starting cold.

What is the real danger of semantic caching, and how do you defend against it?

The danger is false merges: two inputs that look similar to the embedding model but should produce different outputs get collapsed, so one request silently receives another's result. Unlike a latency regression, this failure is invisible — the system returns a confident, well-formed, wrong answer. Defending against it requires tuning the threshold against labeled near-misses, not happy-path matches.

This is the crux of the whole pattern. It is easy to validate a similarity threshold against pairs you already know are the same — they score high, you feel good, you ship. But the threshold's real job is to reject the pairs that are close-but-different. If you never test against those, you have no idea where your false-merge boundary actually sits.

So we built a small labeled set: known same-intent pairs and deliberately chosen adjacent near-misses — inputs that read similarly but must not merge. We tuned the threshold against both. At the chosen value (around 0.62 cosine similarity for our embeddings and text distribution) we measured precision 1.0 — zero wrong merges across the labeled set — at roughly 92% recall of differently-worded same-intent requests.

Two caveats we would underline for anyone copying this. First, 0.62 is not a magic number — it is a function of the embedding model, the text domain, and the normalization upstream of it. Treat any threshold from a blog post as a starting point and re-tune on your own labeled data. Second, treat the chosen value as provisional even after launch: re-tune it against real production traffic, because real inputs are messier than any labeled set you assemble by hand. This is the same lesson we keep relearning when fixing silent failures in multi-step LLM pipelines — the failures that hurt are the quiet ones.

How do you keep the cache from becoming a single point of failure?

Put both layers behind independent feature flags so the pipeline degrades gracefully to no-cache. If L2 misbehaves, flip it off and the system falls back to L1 plus live generation. If the cache subsystem fails entirely, both flags off means every request simply runs the full pipeline — slower and pricier, but correct. The cache is never load-bearing for correctness.

This separation is what let us ship the write path before the read path, tune in production with the read flag off, and turn semantic matching on only once the threshold was validated. A cache you can switch off per-layer is a cache you can actually operate. One you cannot is a liability waiting for its first bad merge.

How do you build a deduped concept graph for free as a byproduct?

Run a semantic-dedup upsert on the write side of every pipeline run. After the final output is produced, each generated item is canonicalized, embedded, and matched against an existing concept library in the same category. Above a dedup threshold (or on an exact concept-string match) you reuse the existing row; otherwise you insert a new one. Then you upsert a join-table link from the item to its source context.

This is a different mechanism from the read cache — it is not a hit/miss path, it is an accumulation pattern. The sequence per generated item is: an LLM canonicalizes the item text into a short concept label, that concept is embedded, the embedding is compared by cosine similarity to existing library rows in the same category, and a match reuses the row while a miss inserts a fresh one. Every run, every item, additively.

The elegant result is that the same concept worded differently across many source contexts collapses into one library row with many links. Over time you accumulate a true many-to-many concept-to-context map — a structured, deduped knowledge graph — purely as a side effect of normal generation. Nobody had to build or curate it; it falls out of running the pipeline.

Because it touches the main pipeline, we wrapped it as strictly best-effort and additive-only. If the canonicalization or embedding call fails for an item, it falls back to a normalized-key insert rather than raising. The accumulation library can never break or block a generation run — at worst it stores a slightly less-deduplicated row that a later pass can merge.

The complete pattern at a glance

The shape is: L1 exact-hash cache absorbs the cheap repeats, L2 semantic cache catches differently-worded same-intent requests with a categorical guard and a threshold tuned against near-misses, both behind flags so the system degrades to correct-but-slow, and a best-effort write-side dedup library that builds a concept graph for free. The hard part was never the embeddings — it was tuning the threshold against the pairs that must not merge. That is the difference between a cache that saves money and one that quietly hands users the wrong answer.