Skip to content

    Forward Deployed Engineer

    LLM Output Guardrails and Tenant-Safe Response Caching

    Add an output guardrail and a tenant-safe response cache to an LLM service in Python, with tests that prove tenant A is never served tenant B's cached answer.

    By Published 8 min read

    Share

    This lesson is part of Forward Deployed Engineer: Agents, Tools and Guardrails. Enroll for free, then sign in, to read it in full.

    What you'll learn: how to check a model's reply before it leaves your service, and how to cache replies without leaking one tenant's answer to another.

    At a glance

    Figure 1. The reply path with a cache and a guardrail

    The Orchestrator looks in the Response cache first; on a miss the Model gateway calls the LLM provider, the Orchestrator sends the raw reply to the Policy engine, and only a checked reply is stored and returnedClient app to Orchestrator (request); Orchestrator to Response cache (key lookup); Orchestrator to Model gateway (on miss); Model gateway to LLM provider (prompt); Orchestrator to Policy engine (check raw reply); Policy engine to Response cache (store if allowed)Client app: tenant + roleClient apptenant + roleOrchestrator: answer()Orchestratoranswer()Response cache: tenant-scoped keyResponse cachetenant-scoped keyModel gateway: on a missModel gatewayon a missLLM provider: outside our controlLLM provideroutside ourcontrolPolicy engine: output guardrailPolicy engineoutput guardrailrequestkey lookupon misspromptcheck raw replystore if allowed

    How to read this: nothing reaches the cache or the client until the guardrail has checked it, and the cache key decides who may be served a stored reply.

    • An output guardrail checks the reply before the client sees it: schema, policy, redaction.
    • It is a filter with known gaps, not a guarantee. Say which gaps.
    • A cache key holds everything the answer depends on: tenant, role, prompt version, question.

    Scenario

    Composite scenario, not a real company. Lumen Industrial Supply, a fictional distributor, runs its product copilot for two tenants, acme and globex. Before a pilot the team worries that a reply might carry an email address or name a partner-only product to a public caller, and the same question arrives many times a day. The FDE adds an output guardrail and a response cache. All numbers are illustrative.

    What you will be able to do

    • Write a guardrail and list what it cannot catch.
    • Build a tenant-safe cache key and prove it with a test.
    • Choose between three kinds of cache.

    Prerequisites

    Core ideas

    The reply path

    Figure 2. One request: a miss, then a hit

    sequenceDiagram accTitle: One request through the cache and the guardrail accDescr: The orchestrator looks up a key in the response cache. On a miss it asks the model gateway for a raw reply, the policy engine checks it, and the checked reply is stored and returned. The same request again is a hit with no model call. participant C as Client app participant O as Orchestrator participant R as Response cache participant M as Model gateway participant P as Policy engine C->>O: request O->>R: get(key) R-->>O: nothing (miss) O->>M: complete M-->>O: raw reply O->>P: check_reply(raw) P-->>O: allow, rewrite or block O->>R: put(key, checked reply) O-->>C: checked reply C->>O: same request again O->>R: get(key) R-->>O: checked reply (hit) O-->>C: checked reply, no model call

    How to read this: the cache holds only checked replies, so a hit never skips the guardrail.

    An output guardrail: schema, policy, redaction

    OWASP names Improper Output Handling as insufficient validation of model output before it passes downstream, and advises treating the model like any other user. Three checks:

    Check Action Lumen example Cannot catch
    Schema block reply is not JSON or lacks citations a well-formed reply that is wrong
    Policy block a SKU the caller may not see; a claim with no citation; a blocked term a wrong claim that cites a real chunk
    Redaction rewrite an email address becomes [email removed] a phone number, a name, "jo at example dot com"

    Verified: capstone/fde-product-discovery-copilot/lessons/M4-L9/m4_l9_guardrails_cache.py

    def check_reply(raw: str, allowed_skus: set[str], blocked_terms: tuple[str, ...] = ()) -> GuardResult:
        """Schema, then policy (block), then redaction (rewrite). The caller never sees `raw` on a block."""
        try:
            draft = parse_json_model(Draft, raw)
        except (ValueError, ValidationError):
            return GuardResult("block", REFUSAL_MESSAGE, ["schema"])
        reasons: list[str] = []
        for item in draft.recommendations:
            if item.sku not in allowed_skus:
                reasons.append(f"sku not allowed: {item.sku}")
            if not item.citations:
                reasons.append(f"no citation: {item.sku}")
            if any(term in item.why.lower() for term in blocked_terms):
                reasons.append(f"blocked term: {item.sku}")
        if reasons:
            return GuardResult("block", REFUSAL_MESSAGE, reasons)
        cleaned = EMAIL.sub(REDACTED, raw)
        return GuardResult("rewrite" if cleaned != raw else "allow", cleaned, ["email"] if cleaned != raw else [])

    allowed_skus must come from the verified identity, never from the request.

    WARNING

    The guardrail checks what a reply contains, not whether it is true. That is the job of citations and evaluation.

    Three kinds of cache

    The semantic cache is design reasoning, not a provider feature.

    Provider prompt caching Your response cache Semantic cache
    What is stored the start of the prompt the final reply a reply found by similarity
    Who controls it you opt in, the provider holds it you you, plus a threshold
    Hit needs an identical prompt start the same key a close enough question
    Main risk a short prefix gives no saving a leaking key, a stale answer two different questions match
    Model called on a hit yes no no

    As of 2026-11-03 the Anthropic page says:

    • the cache covers tools, system and messages, in that order;
    • a hit needs 100 percent identical prompt segments;
    • the default lifetime is 5 minutes;
    • a prompt below a model-specific minimum length is processed without caching, and no error is returned;
    • caching has no effect on output generation;
    • different organizations never share caches, and on the Claude API caches are also isolated by workspace.

    Tenants served from your one workspace share its cache, so the last line does not separate them.

    A response cache that cannot leak

    HTTP caches face the same problem. RFC 9111 keys a cache on at least the method and target URI, uses Vary to name the request headers that changed the response, and bars a shared cache from reusing an Authorization response unless allowed. Our key names what changed the answer.

    Key part Without it
    tenant acme is served globex's answer
    role a public caller gets a partner-only answer
    prompt version old-prompt answers after a release
    normalised question spacing and case differences miss the cache

    Verified: capstone/fde-product-discovery-copilot/lessons/M4-L9/m4_l9_guardrails_cache.py

    def normalise(request: str) -> str:
        """Lower case and collapse spaces. Digits stay, so '40 sensors' and '400 sensors' differ."""
        return " ".join(request.casefold().split())
    
    
    def cache_key(identity: Identity, prompt_version: str, request: str) -> str:
        """Everything the answer depends on: who may see it, which prompt made it, what was asked."""
        parts = [identity.tenant, identity.role, prompt_version, normalise(request)]
        return hashlib.sha256(json.dumps(parts).encode()).hexdigest()
    TIP

    Normalise lightly: every extra rule merges more questions, and a wrongly merged pair is a wrong answer served confidently.

    Spot the flaw. A teammate caches replies with this key function. Which requests share an entry, and what goes wrong?

    Verified: capstone/fde-product-discovery-copilot/lessons/M4-L9/m4_l9_guardrails_cache.py

    def naive_key(request: str) -> str:
        """Cache key from the question text."""
        return hashlib.sha256(request.strip().encode()).hexdigest()
    Answer

    The key holds only the question, so acme and globex asking "Vibration sensor?" share one entry, and whoever asked first decides what the other sees. The same flaw serves partner-only answers to public callers, and old-prompt answers after a release. The fix is the key above.

    Worked example

    Six requests go through one cache with a 60-second time to live (TTL: how long an entry may be served). The clock is moved by hand.

    t= 0s acme   public  v1 miss
    t= 5s acme   public  v1 hit
    t= 6s globex public  v1 miss
    t= 7s acme   partner v1 miss
    t= 8s acme   public  v2 miss
    t=70s acme   public  v1 miss
    • t=5: same tenant, role, version and normalised question: a hit.
    • t=6, t=7, t=8: a different tenant, role or prompt version means a different key: a miss.
    • t=70: the t=0 entry expired at t=60: a miss, so a changed catalog is picked up within one TTL.
    • Assumptions (illustrative): one reply, a 60-second TTL. One hit in six is a 16.7 percent hit rate, a toy figure; yours comes from your logs.

    At the customer: "Can another customer see our answers through the cache?" Show the key, run the isolation test, state the TTL and how to flush, and name what the guardrail misses.

    Build it

    The code is in capstone/fde-product-discovery-copilot/lessons/M4-L9/. Run the trace with PYTHONPATH=. ./.venv/Scripts/python lessons/M4-L9/m4_l9_guardrails_cache.py and the tests with ./.venv/Scripts/python -m pytest lessons/M4-L9 -q. The 19 tests use a scripted provider and no network. They pin the known gaps, the isolation proofs, expiry at the TTL, and that a blocked reply is never cached.

    Verified: capstone/fde-product-discovery-copilot/lessons/M4-L9/m4_l9_guardrails_cache.py

    class ResponseCache:
        def __init__(self, ttl_s: float, clock: Callable[[], float] = time.monotonic) -> None:
            self._items: dict[str, tuple[float, str]] = {}
            self._ttl = ttl_s
            self._clock = clock
            self.hits = 0
            self.misses = 0
    
        def get(self, key: str) -> str | None:
            entry = self._items.get(key)
            if entry is not None and entry[0] > self._clock():
                self.hits += 1
                return entry[1]
            self._items.pop(key, None)  # expired or absent
            self.misses += 1
            return None
    
        def put(self, key: str, value: str) -> None:
            self._items[key] = (self._clock() + self._ttl, value)
    NOTE

    The clock is a parameter so a test can move time. Python documents time.monotonic as a clock that cannot go backwards.

    Verified: capstone/fde-product-discovery-copilot/lessons/M4-L9/m4_l9_guardrails_cache.py

    def answer(
        produce: Callable[[], str], cache: ResponseCache, identity: Identity, prompt_version: str,
        request: str, allowed_skus: set[str],
    ) -> GuardResult:
        """Cache first. On a miss: call the model, guard the reply, and cache it only if it was not blocked."""
        key = cache_key(identity, prompt_version, request)
        if (hit := cache.get(key)) is not None:
            return GuardResult("allow", hit, ["cache hit"])
        result = check_reply(produce(), allowed_skus)
        if result.action != "block":
            cache.put(key, result.text)
        return result
    WARNING

    This cache lives in one process's memory with no size limit and no flush. A shared store and a size cap are next; the key stays the same.

    Decision exercise. Lumen's sales engineers ask the same questions all day, the prompt start is identical, and the catalog changes during the day. Choose one:

    • A. Provider prompt caching only.
    • B. Provider prompt caching plus a response cache keyed by tenant, role, prompt version and question, with a short TTL.
    • C. A semantic cache shared by all tenants.
    Model answer

    B. Prompt caching cuts the cost of the repeated prompt start, but the model is still called; the response cache removes the call, and the short TTL bounds staleness. C shares entries across tenants and roles, and lets a different question match. A still calls the model for every repeat.

    Guardrail and cache: decide (interactive when JavaScript is on)

    The team deploys prompt version v2. Acme's entries from v1 still have time left on their TTL. What happens to acme's next request?

    • It is served the v1 reply until the entry expires.
    • It misses, because the prompt version is part of the key, and the model is called.
    • It hits, because the tenant and the question have not changed.
    Answer

    It misses, because the prompt version is part of the key, and the model is called.. The version is part of the key, so a v2 request cannot find a v1 entry.

    A reply contains "Write to jo at example dot com." The guardrail redacts email addresses with a pattern. What reaches the client?

    • The reply with the address replaced by [email removed].
    • The reply is blocked because the schema check fails.
    • The reply unchanged, because the pattern does not match this spelling.
    Answer

    The reply unchanged, because the pattern does not match this spelling.. The pattern matches addresses with an at sign, not spelled-out ones; a test pins this gap. The schema is valid, so nothing blocks it.

    A partner user at acme asks a question and the reply is cached. A public user at acme then asks the same words. What happens?

    • A miss, because the role is part of the key, so the model is called.
    • A hit, because they belong to the same tenant.
    • A hit, because the normalised questions are equal.
    Answer

    A miss, because the role is part of the key, so the model is called.. The role is in the key, so a public caller is never served a partner's entry. Same tenant and same words are not enough.

    Exercise

    In a new file under lessons/M4-L9/, wrap check_reply (do not edit the module) with a redaction for phone numbers like +49 30 1234567, and test it.

    Acceptance criteria:

    • a reply with a phone number comes back rewritten and still valid JSON;
    • a reply without one is unchanged;
    • a test pins one phone format your pattern misses.

    Done when your tests, the 19 existing ones and the whole capstone suite pass, and you can say in two sentences why a pattern is a filter, not a guarantee.

    Hint 1: where the pattern goes

    Redact after the inner check passes, then confirm the result still parses.

    Hint 2: how to keep it honest

    Write the failing case first: a number with dots.

    Common mistakes

    • Keying the cache on the question alone, or on tenant but not role.
    • Caching before the guardrail runs, or caching a blocked reply.
    • Normalising so hard that "40 sensors" and "400 sensors" merge.
    • No TTL and no flush, so a changed catalog never reaches users.

    Knowledge check

    1. Why does the cache key include the role and not only the tenant?

    A partner may see products a public caller may not; without the role, the first answer goes to both.

    2. Why is the guardrail run before the cache write, not after the read?

    Only checked replies are stored, so every hit has already passed the guardrail. Checking after the read would repeat the work.

    3. Does provider prompt caching make your response cache unnecessary?

    No. The provider's page says caching has no effect on output generation, so the model still generates each reply.

    4. What does the email pattern miss, and why does the page say so?

    Spelled-out addresses, phone numbers and names. A pattern is a filter with known gaps, so state and test them.

    5. What does the TTL protect against?

    It bounds how long a stale answer is served. It cannot stop a leaking key.

    You can now

    • Write and test a three-step guardrail and name its gaps.
    • Build a cache key from tenant, role, prompt version and question.
    • Prove tenant isolation and expiry with tests.

    Further reading

    Last verified: 2026-11-03

    Next

    This is an optional page. Back to the core path: Human approval gate for AI agent actions

    Enroll to keep reading

    Lesson 8 of 8 · Forward Deployed Engineer: Agents, Tools and Guardrails

    Add an output guardrail and a tenant-safe response cache to an LLM service in Python, with tests that prove tenant A is never served tenant B's cached answer.

    In the rest of this lesson

    • Worked example
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Agents, Tools and Guardrails, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments