{"id":302,"date":"2026-09-22T17:48:16","date_gmt":"2026-09-22T09:48:16","guid":{"rendered":"https:\/\/wp.qoraapi.com\/llm-output-guardrails\/"},"modified":"2026-09-22T17:49:31","modified_gmt":"2026-09-22T09:49:31","slug":"llm-output-guardrails","status":"publish","type":"post","link":"https:\/\/qoraapi.com\/blog\/llm-output-guardrails\/","title":{"rendered":"Output Guardrails: Validating LLM Responses in Production"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Validate the model&#8217;s output because you cannot prove the absence of a failure mode by writing a better instruction. A prompt is a request, not a contract. The same input produces different output across model versions, across providers serving the same weights, and across supposedly identical temperature-0 calls, because batching, kernel selection and floating-point reduction order are not deterministic. Output guardrails are the layer that turns &#8220;the model usually does this&#8221; into &#8220;the system only ever ships this&#8221; \u2014 and they run after generation, not inside the prompt.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why you validate the output instead of hardening the prompt<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Instruction-following is best-effort. Writing &#8220;always return valid JSON&#8221; shifts the probability distribution of outputs; it does not create an invariant. That matters because prompt failures are silent: a model that ignores a format instruction returns something plausible until a parser hits it at 3am, whereas a validator fails loudly, at the boundary, with the offending value attached.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Non-determinism has several sources and only one is the temperature parameter. Greedy decoding at temperature 0 breaks ties in the logits, but the logits are a floating-point computation whose reduction order depends on batch size, sequence packing and tensor-parallel layout. Change the batch size and a token ahead by 0.001 logits can flip. Mixture-of-experts routing adds another: a request landing on a different expert set produces a different continuation from the same checkpoint. Version pinning narrows the distribution rather than freezing it, because a pinned snapshot can still be served on different hardware.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So a prompt regression suite tells you what changed, not what is safe right now. Prompts need versioning and review; guardrails are runtime code that needs tests, metrics and an owner. This is about validating what the model <em>says<\/em>, not what it <em>does<\/em> \u2014 constraining actions is a separate control with a separate threat model, covered in <a href=\"https:\/\/qoraapi.com\/blog\/sandboxing-ai-tool-calls\/\">sandboxing AI tool calls<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The validation layers, cheapest first<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Run the layers in ascending cost order and stop at the first blocking verdict. A structural failure makes every later check meaningless, and a regex hit is cheaper to act on than a classifier score. Ordering also keeps expensive layers&#8217; error rates out of the picture when a cheap layer already has the answer.<\/p>\n\n\n\n<figure class=\"wp-block-table is-style-stripes\"><table class=\"has-fixed-layout\">\n<thead>\n<tr><th>Layer<\/th><th>Added latency<\/th><th>Marginal cost<\/th><th>What it catches<\/th><th>False-positive profile<\/th><\/tr>\n<\/thead>\n<tbody>\n<tr><td>Structural (parse, JSON Schema)<\/td><td>0.1-2 ms<\/td><td>CPU only<\/td><td>Truncated output, wrong types, missing fields, invented enum values, malformed tool arguments<\/td><td>Near zero if the schema is derived from real payloads; high if the schema is aspirational<\/td><\/tr>\n<tr><td>Deterministic rules (regex, allow-lists, bounds)<\/td><td>under 1 ms<\/td><td>CPU only<\/td><td>Secret-shaped strings, banned phrases, competitor names, over-length answers, numbers outside a plausible range, tool names not on the allow-list<\/td><td>Entirely a function of how precisely the patterns are written; over-broad regex is the largest source of false positives in most stacks<\/td><\/tr>\n<tr><td>Groundedness (claim vs retrieved context)<\/td><td>0.3-2 s with a judge; under 50 ms for the prefilter<\/td><td>Cheap for substring and embedding lookups, one model call for the residue<\/td><td>Claims with no support in the context, and claims the context directly contradicts<\/td><td>High when retrieval was truncated or the answer legitimately draws on parametric knowledge; needs a prefilter or it blocks good answers<\/td><\/tr>\n<tr><td>Classifier-based policy checks<\/td><td>20-150 ms<\/td><td>GPU or hosted classification endpoint, priced per request<\/td><td>Toxicity, system-prompt self-disclosure, injection payloads echoed back, competitor mentions, PII in free text<\/td><td>Dominated by the threshold you pick; you cannot reason about it, you have to measure it on labelled data<\/td><\/tr>\n<tr><td>Human review (sampled)<\/td><td>Minutes to hours<\/td><td>Highest, by orders of magnitude<\/td><td>Novel failure modes, calibration of the automated layers, cases where two classifiers disagree<\/td><td>Not applicable, but throughput is capped at tens to hundreds of items per reviewer per day<\/td><\/tr>\n<\/tbody>\n<\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two properties matter more than the list. Each layer should return findings rather than a boolean, so the final verdict comes from a policy object instead of being hard-coded inside each check. And the false-positive column decides whether a layer ships, not the true-positive column: a detector that catches every real leak but blocks 8 percent of valid traffic is not a detector, it is an outage.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Schema validation in practice<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Make the schema strict enough to be worth running<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A schema asserting <code>{\"type\": \"object\"}<\/code> catches nothing. The useful constraints are the boring ones: <code>additionalProperties: false<\/code> so invented fields surface instead of passing downstream, <code>required<\/code> on every field the consumer dereferences, <code>enum<\/code> on anything categorical, and <code>maxItems<\/code> and <code>maxLength<\/code> to bound payload size.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Provider strict modes are worth enabling \u2014 the strict <code>json_schema<\/code> response format, Anthropic tool-use schemas, Gemini&#8217;s <code>responseSchema<\/code> \u2014 but they remove syntax-level failures, not semantic ones, and they do not survive a failover. A strict schema guarantees the shape of the answer, never its truth. Mechanics are in <a href=\"https:\/\/qoraapi.com\/blog\/ai-structured-outputs-json-mode\/\">structured outputs and JSON mode<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">A repair loop with a hard attempt cap<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When validation fails, the cheapest fix is usually to re-ask the same model with the validator&#8217;s error attached. That works often, because the failure is frequently a formatting slip rather than a reasoning failure. It needs a cap: every attempt is a full round trip with full input tokens, so two attempts on a 4,000-token prompt is three times the input cost of a single call.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import json\nfrom dataclasses import dataclass\nfrom typing import Any, Callable\n\nimport jsonschema\n\nINVOICE_SCHEMA: dict[str, Any] = {\n    \"type\": \"object\",\n    \"additionalProperties\": False,\n    \"required\": [\"vendor\", \"currency\", \"total_cents\", \"line_items\"],\n    \"properties\": {\n        \"vendor\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 200},\n        \"currency\": {\"type\": \"string\", \"enum\": [\"USD\", \"EUR\", \"GBP\"]},\n        \"total_cents\": {\"type\": \"integer\", \"minimum\": 0},\n        \"line_items\": {\n            \"type\": \"array\",\n            \"minItems\": 1,\n            \"maxItems\": 200,\n            \"items\": {\n                \"type\": \"object\",\n                \"additionalProperties\": False,\n                \"required\": [\"description\", \"amount_cents\"],\n                \"properties\": {\n                    \"description\": {\"type\": \"string\", \"maxLength\": 500},\n                    \"amount_cents\": {\"type\": \"integer\", \"minimum\": 0},\n                },\n            },\n        },\n    },\n}\n\nMAX_REPAIR_ATTEMPTS = 2\n\n@dataclass(frozen=True)\nclass SchemaResult:\n    ok: bool\n    value: dict[str, Any] | None\n    attempts: int\n    errors: list[str]\n\ndef _describe(exc: Exception) -&gt; str:\n    if isinstance(exc, jsonschema.ValidationError):\n        path = \"\/\".join(str(p) for p in exc.absolute_path) or \"&lt;root&gt;\"\n        return f\"{path}: {exc.message}\"\n    return str(exc)\n\ndef parse_with_repair(\n    raw: str,\n    call_model: Callable[[str, float], str],\n    temperature: float = 0.0,\n) -&gt; SchemaResult:\n    \"\"\"Validate raw output; on failure re-ask with the validator error attached.\n\n    call_model is injected so this is unit-testable without a network call.\n    \"\"\"\n    errors: list[str] = []\n    candidate = raw\n    for attempt in range(MAX_REPAIR_ATTEMPTS + 1):\n        try:\n            value = json.loads(candidate)\n            jsonschema.validate(value, INVOICE_SCHEMA)\n        except (json.JSONDecodeError, jsonschema.ValidationError) as exc:\n            errors.append(_describe(exc))\n        else:\n            return SchemaResult(True, value, attempt, [])\n\n        if attempt == MAX_REPAIR_ATTEMPTS:\n            break\n\n        candidate = call_model(\n            \"Your previous reply failed validation. Fix only the problems listed. \"\n            \"Do not add, remove or reinterpret fields, and do not change a value \"\n            \"that was not listed as invalid.\\n\"\n            \"Errors:\\n\" + \"\\n\".join(f\"- {e}\" for e in errors[-3:]) + \"\\n\"\n            \"Return the corrected JSON object and nothing else.\",\n            temperature,\n        )\n\n    return SchemaResult(False, None, MAX_REPAIR_ATTEMPTS, errors)\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">What the repair loop must never do<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The repair prompt above carries a sentence doing real work: <em>do not change a value that was not listed as invalid<\/em>. Without it, a model asked to fix a schema error will often rewrite the data to make the error disappear \u2014 rounding a total so the line items sum, deleting the field with the wrong type, reclassifying a currency to fit the enum. That is data corruption dressed as a successful repair.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Cross-field semantic checks belong outside the repair loop. If your validator asserts that <code>sum(line_items) == total_cents<\/code>, a repair cannot fix a mismatch, because the model cannot know which side is wrong. Block and route to a human. A repair rate jumping from 2 percent to 15 percent after a prompt edit is the earliest signal of a regression.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Groundedness: unsupported is not the same as contradicted<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Extract the atomic claims, then look for support for each in the context you actually retrieved. Three outcomes, and the third is the important one. <strong>Supported<\/strong>: a span in the context entails the claim. <strong>Unsupported<\/strong>: nothing speaks to it either way \u2014 the model may be using parametric knowledge, or retrieval was truncated. <strong>Contradicted<\/strong>: the context asserts the opposite. Contradicted claims should be blocked or rewritten; unsupported claims should be downgraded or shown with a citation requirement, because blocking every unsupported claim destroys a summarizer that adds one reasonable inference. See <a href=\"https:\/\/qoraapi.com\/blog\/detect-reduce-hallucinations\/\">detecting and reducing hallucinations<\/a> for the taxonomy in depth.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Per-claim judging is quadratic in the obvious implementation, so add a prefilter: normalize each claim, then check for a near-verbatim span, an embedding similarity above a tuned threshold, or a token-overlap ratio. Only the residue goes to a judge, and the judge call is batched \u2014 one request containing every unresolved claim, not one per claim.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Worked example. A response contains 40 atomic claims. The prefilter resolves 28 of them, or 70 percent, without a model call. The remaining 12 go to a judge in one batched request returning per-claim entailment labels in roughly 400 ms. Added latency is one round trip, not twelve. Calling the judge once per unresolved claim at 350 ms each would add 4.2 seconds, and the feature would be disabled within a week.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you use a judge, use a different model than the generator, or at minimum a different prompt with a strict rubric. Ask for a span-level verdict \u2014 &#8220;quote the sentence that supports this claim, or answer NONE&#8221; \u2014 rather than a scalar score. A judge forced to produce a citation cannot reward itself with a vague 0.8.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">PII and secret leakage in the output<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Deterministic detectors cover more than people expect: card numbers with a Luhn check, IBANs with mod-97, national identifier formats, AWS access key prefixes, JWTs by their three-segment structure, PEM private key blocks, connection strings with embedded credentials. Use the checksum wherever the format has one, because a bare digit pattern also matches order numbers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Regex alone misses the cases that matter: a paraphrased address, a phone number written in words, a name split across a token boundary, an email base64-encoded into a code block, &#8220;the card ending in 4242&#8221;, or an identifier the model reconstructed from context rather than copied. For free-text names, addresses and locations you need a NER or classifier pass in addition, and it will be the layer with the least predictable error rate.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Redaction versus blocking is a decision about reversibility, not severity. Mask an email as a stable placeholder when the consumer only needs to know an email was present, and keep the mapping server-side. Block when the value is a credential, a regulated identifier, or anything whose exposure is itself the harm. And never log the raw pre-guardrail output: if your guardrail redacts PII and your observability pipeline then stores the original response body, the guardrail has achieved nothing except latency. Log the post-guardrail text plus finding metadata \u2014 layer, rule, span offsets, verdict \u2014 and put raw output, if you must keep it, in a separate store with short retention and its own audit trail. The wider constraints are in <a href=\"https:\/\/qoraapi.com\/blog\/ai-data-privacy-gdpr\/\">AI data privacy and GDPR<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Fail-open or fail-closed, decided per surface<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Fail-open means that when the guardrail itself errors or times out, the output ships. Fail-closed means it does not. The answer is not global, and it is not something you discover in an <code>except<\/code> block \u2014 it is configuration attached to the surface.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Blast radius decides. A support chatbot failing open ships one bad answer to one user who can ask again; the damage is bounded. The same chatbot failing closed turns every classifier timeout into &#8220;something went wrong&#8221; for the whole user base, an availability incident you caused yourself. A clinical triage flow inverts the calculus: failing open can produce advice that causes physical harm, while failing closed sends the user to a phone number a human answers. Internal analytics fails open by default, because a wrong number in a dashboard is cheap to correct.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Be precise about timeouts. A classifier that times out has said nothing about the content; treating that as &#8220;unsafe&#8221; produces random outages under load, and load is exactly when timeouts cluster.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Latency budgeting and the streaming problem<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Guardrails add round trips, and the budget is tighter than teams assume. Run deterministic checks inline; they cost microseconds. Run the classifier and the groundedness check concurrently, because they are independent, so the wall-clock cost is the maximum rather than the sum.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Worked example. A response is 600 tokens. The PII detector takes 15 ms, the toxicity classifier 90 ms, the policy classifier 120 ms and the groundedness judge 400 ms. Serially that is 625 ms of added latency before the first token reaches the client. With <code>asyncio.gather<\/code> over the last three it is <code>max(90, 120, 400) = 400 ms<\/code>. The judge is the critical path, which is why the groundedness prefilter buys more than any micro-optimization of the regexes.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Hold-back windows and retraction UX<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Streaming and blocking are in direct tension. Once a token is on the user&#8217;s screen you cannot un-show it; you can only append a retraction, and you should assume the user read the original. Three patterns work. First-N-token gating buffers the opening 40 to 80 tokens, validates it, and releases the stream if it passes; most violations are visible in the opening. A sliding hold-back window keeps the last W tokens unreleased while checks run, bounding exposure to the window. Full buffering generates, validates, then renders, which for a 600-token answer at 60 tokens per second moves time-to-first-token from about 0.5 s to about 10 s.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>type GateResult = { block: boolean; reason?: string };\n\nexport type Gate = {\n  holdbackChars: number;\n  check: (unreleased: string) =&gt; Promise&lt;GateResult&gt;;\n};\n\nexport async function* guardedStream(\n  source: AsyncIterable&lt;string&gt;,\n  gate: Gate,\n): AsyncGenerator&lt;string&gt; {\n  let pending = \"\";\n  let released = 0;\n\n  for await (const chunk of source) {\n    pending += chunk;\n\n    \/\/ Only inspect text that has not reached the client yet.\n    const unreleased = pending.slice(released);\n    if (unreleased.length &lt; gate.holdbackChars) continue;\n\n    const { block, reason } = await gate.check(unreleased);\n    if (block) {\n      yield `\\n\\n[Response withheld: ${reason ?? \"policy\"}]`;\n      return;\n    }\n\n    yield unreleased;\n    released = pending.length;\n  }\n\n  const tail = pending.slice(released);\n  if (tail) {\n    const { block, reason } = await gate.check(tail);\n    yield block ? `\\n\\n[Response withheld: ${reason ?? \"policy\"}]` : tail;\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The window bounds the text a user can see before a violation is caught, so a retraction undoes a sentence rather than a paragraph. It does not help when the violation is a single token in the middle of a long answer. For high-harm surfaces, accept the latency and buffer the whole response.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Fallback strategies<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A blocked response is a routing decision, not an error state. Choose the fallback before you ship the guardrail, because inventing one during an incident produces worse outcomes than a boring canned string.<\/p>\n\n\n\n<figure class=\"wp-block-table is-style-stripes\"><table class=\"has-fixed-layout\">\n<thead>\n<tr><th>Fallback<\/th><th>Use when<\/th><th>Added latency<\/th><th>Cost per event<\/th><th>What the user sees<\/th><\/tr>\n<\/thead>\n<tbody>\n<tr><td>Safe canned response<\/td><td>The block was for an out-of-scope or off-policy request<\/td><td>Under 10 ms<\/td><td>Effectively zero<\/td><td>A short answer that declines and points to docs or a human<\/td><\/tr>\n<tr><td>Degrade to a smaller model with a stricter prompt<\/td><td>The violation looks prompt-shaped and a constrained model is good enough for the task<\/td><td>One extra generation, 300 ms to 2 s<\/td><td>One additional generation plus the blocked one<\/td><td>A slightly less rich but valid answer<\/td><\/tr>\n<tr><td>Hand off to a human<\/td><td>The domain is high-harm and the request is legitimate but unresolvable automatically<\/td><td>Minutes<\/td><td>Highest, and unbounded per item<\/td><td>A queue position, a callback promise or a ticket number<\/td><\/tr>\n<tr><td>Structured error<\/td><td>The caller is a machine, not a person<\/td><td>Under 10 ms<\/td><td>Effectively zero<\/td><td>A typed error with a retryable flag and the failing rule id<\/td><\/tr>\n<\/tbody>\n<\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two rules of thumb. Degrade to a smaller model when the failure came from a permissive prompt, and not when the task is simply hard \u2014 a weaker model on a hard task produces a confident wrong answer, which is worse than a block. And never let a fallback re-enter the same guardrail loop without a depth limit, or a persistent violation becomes an infinite regeneration loop.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Measuring a guardrail you intend to ship<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Build a labelled set before shipping the layer: a sample of real traffic, human-labelled at the exact verdict granularity you ship, plus a synthetic adversarial set covering the failures you worry about. Then track precision, recall and the false-positive rate on live traffic, split by layer, because a blended number hides which layer is misfiring.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Worked example. You serve 1,000,000 responses a month and the blocking guardrail fires on 0.5 percent of them, so 5,000 blocks. If the measured false-positive rate on known-good outputs is 5 percent, then roughly <code>0.05 x 995,000 = 49,750<\/code> legitimate responses were blocked. You have manufactured an incident an order of magnitude larger than the one you were preventing. You cannot measure that 5 percent from blocked traffic alone \u2014 it requires labelling outputs you allowed, which is the step teams skip.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two more metrics belong on the same dashboard: repair-loop rate, a leading indicator of prompt or model regression, and block rate per rule, because a rule whose block rate is zero is either perfect or broken and it is almost never perfect. Re-run the labelled set on every model version change. Keeping model and prompt changes traceable alongside these numbers is where <a href=\"https:\/\/qoraapi.com\/blog\/llm-observability\/\">LLM observability<\/a> earns its keep. A guardrail nobody measured is one you cannot ship.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">A concrete pipeline<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The shape that survives production is a set of layer functions returning findings, a policy object resolved per surface, and a verdict computed by severity rather than control flow. The classifier is injected, so the whole thing is testable with a stub.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import asyncio\nimport re\nimport time\nfrom dataclasses import dataclass, field\nfrom enum import Enum\nfrom typing import Awaitable, Callable\n\nclass Verdict(str, Enum):\n    ALLOW = \"allow\"\n    REDACT = \"redact\"\n    BLOCK = \"block\"\n    REVIEW = \"review\"\n\n@dataclass\nclass Finding:\n    layer: str\n    rule: str\n    verdict: Verdict\n    detail: str\n\n@dataclass\nclass Outcome:\n    verdict: Verdict\n    text: str\n    findings: list[Finding] = field(default_factory=list)\n    timings_ms: dict[str, float] = field(default_factory=dict)\n\nSECRET_PATTERNS = {\n    \"aws_access_key\": re.compile(r\"\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b\"),\n    \"private_key_block\": re.compile(r\"-----BEGIN [A-Z ]*PRIVATE KEY-----\"),\n    \"jwt\": re.compile(r\"\\beyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\b\"),\n}\n\nPolicy = dict[str, Verdict]\n\n# One explicit policy per surface. Fail-open and fail-closed are data here,\n# not an except block somewhere in the request handler.\nPOLICIES: dict[str, Policy] = {\n    \"support_chat\": {\n        \"structural\": Verdict.BLOCK, \"policy\": Verdict.BLOCK,\n        \"grounded\": Verdict.REDACT, \"on_error\": Verdict.ALLOW,\n    },\n    \"clinical_triage\": {\n        \"structural\": Verdict.BLOCK, \"policy\": Verdict.BLOCK,\n        \"grounded\": Verdict.BLOCK, \"on_error\": Verdict.BLOCK,\n    },\n    \"internal_analytics\": {\n        \"structural\": Verdict.REVIEW, \"policy\": Verdict.REVIEW,\n        \"grounded\": Verdict.ALLOW, \"on_error\": Verdict.ALLOW,\n    },\n}\n\nWORST_FIRST = (Verdict.BLOCK, Verdict.REDACT, Verdict.REVIEW, Verdict.ALLOW)\n\ndef _worst(findings: list[Finding], fallback: Verdict) -&gt; Verdict:\n    for verdict in WORST_FIRST:\n        if any(f.verdict is verdict for f in findings):\n            return verdict\n    return fallback\n\ndef redact(text: str, findings: list[Finding]) -&gt; str:\n    out = text\n    for f in findings:\n        if f.layer == \"pii\" and f.detail:\n            out = out.replace(f.detail, f\"[REDACTED:{f.rule}]\")\n    return out\n\nasync def run_guardrails(\n    text: str,\n    surface: str,\n    *,\n    validate_structure: Callable[[str], list[Finding]],\n    classify: Callable[[str], Awaitable[list[Finding]]],\n    check_groundedness: Callable[[str], Awaitable[list[Finding]]],\n) -&gt; Outcome:\n    policy = POLICIES[surface]\n    timings: dict[str, float] = {}\n\n    start = time.perf_counter()\n    structural = validate_structure(text)\n    timings[\"structural\"] = (time.perf_counter() - start) * 1000\n    if structural:\n        # A structural failure makes every later layer meaningless.\n        return Outcome(_worst(structural, policy[\"structural\"]), text, structural, timings)\n\n    start = time.perf_counter()\n    rule_findings = [\n        Finding(\"rules\", name, Verdict.BLOCK, \"secret-shaped string in output\")\n        for name, pattern in SECRET_PATTERNS.items()\n        if pattern.search(text)\n    ]\n    timings[\"rules\"] = (time.perf_counter() - start) * 1000\n\n    start = time.perf_counter()\n    policy_findings, grounded_findings = await asyncio.gather(\n        classify(text), check_groundedness(text)\n    )\n    timings[\"classifier_and_grounded\"] = (time.perf_counter() - start) * 1000\n\n    findings = rule_findings + policy_findings + grounded_findings\n    verdict = _worst(findings, Verdict.ALLOW)\n    if verdict is Verdict.REDACT:\n        text = redact(text, findings)\n\n    return Outcome(verdict, text, findings, timings)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three choices in that code are load-bearing. Structural failures short-circuit, because there is nothing useful to say about the policy of unparseable output. Classifier and groundedness run under one <code>gather<\/code>, so added latency tracks the slower of the two. And the error path lives in <code>POLICIES<\/code> rather than a bare <code>except<\/code>, so an operator can change it without reading the request handler.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What I would ship first for a customer-facing product<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Ship structural validation with a bounded repair loop first, before any classifier. It is free, its false-positive rate is near zero when the schema comes from real payloads, and it eliminates the failures that page you at 3am: truncated JSON, missing fields, wrong types. Instrument the repair rate from day one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Second, ship deterministic PII and secret detection, fail-closed on secrets and redact on everything else, with raw output kept out of logs from the first commit. It is cheap, defensible in a security review, and the layer most likely to catch a genuine incident.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Third, buffer the full response for the single highest-harm surface, and only that surface. Non-streaming is a real product cost, so spend it where the blast radius justifies it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Do not ship a policy classifier as a blocker before you have a labelled set and a measured false-positive rate. Do not ship an LLM-as-judge groundedness check as a blocker at all in the first release: run it in shadow mode, log what it would have blocked, and label a few hundred of those decisions by hand. Until you can state your false-positive rate with a number attached, it is a measurement, not a control.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Put model routing behind a gateway rather than wiring providers into application code, so pinning a version, swapping a judge or failing over is a configuration change instead of a deploy.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Do I still need output validation if I use a provider&#8217;s strict structured outputs mode?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, for three reasons. Strict modes constrain syntax, not semantics: a strict schema will happily return a well-typed total that does not equal the sum of its line items. They are provider-specific, so the guarantee disappears the moment your gateway fails over. And they say nothing about groundedness, PII or policy.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should the groundedness judge be a different model from the generator?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Preferably yes, and at minimum it must be a different prompt with an explicit rubric. A judge sharing the generator&#8217;s system prompt inherits the same framing and will rationalise the generator&#8217;s output. If you can only afford one model, demand a quoted supporting span for every supported verdict.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I stream tokens and still enforce guardrails?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Partially. Use a hold-back window so the text you validate has not reached the client, and accept that a late violation still needs a retraction. First-N-token gating covers violations in the opening sentence. Where a leaked token is unacceptable, do not stream.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I handle a guardrail false positive in production?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Make it reviewable rather than just blockable. Store the finding, the rule id, the span and a hash of the output, so a reviewer can judge the decision without storing raw text, and give the reviewer an override that feeds back into the labelled set. If one rule accounts for most of your false positives, fix that rule rather than raising the layer threshold.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do guardrails belong in the client, the gateway, or the application?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Split them by what each knows. The application owns semantic checks, because only it knows the schema, the retrieved context and the policy per surface. The gateway owns the cross-cutting pieces: version pinning, provider failover, redacted logging, rate limits. The client should own nothing that matters for safety.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Output guardrails are the executable specification of what your system is allowed to emit. Order them by cost, make each layer return findings rather than booleans, resolve fail-open versus fail-closed per surface as configuration, and measure the false-positive rate before anything is allowed to block. The layers that ship first are the boring ones: schema validation with a bounded repair loop, deterministic secret and PII detection, and full buffering where a leaked token is unacceptable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The alternative is not &#8220;no guardrails&#8221;, it is unmeasured guardrails: regexes nobody tuned, a threshold copied from a blog post, and no idea how many good answers you blocked last month. Routing model traffic through a single OpenAI-compatible endpoint such as <a href=\"https:\/\/qoraapi.com\/\">qoraapi.com<\/a> makes the operational side tractable \u2014 pinned versions, failover, one place to enforce redaction and logging \u2014 but the validation logic is still yours to design.<\/p>\n\n\n\n\n<h3 class=\"wp-block-heading\">Related reading<\/h3>\n\n\n<ul class=\"wp-block-list\"><li><a href=\"https:\/\/qoraapi.com\/blog\/detect-reduce-hallucinations\/\">Detecting and Reducing Hallucinations in Production LLM Apps<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/ai-structured-outputs-json-mode\/\">AI Structured Outputs Explained: JSON Mode, Schema Enforcement, Reliable Parsing<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/sandboxing-ai-tool-calls\/\">Sandboxing AI Tool Calls: Preventing Data Exfiltration<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/ai-api-security\/\">AI API Security: Protecting Keys and Preventing Abuse<\/a><\/li><\/ul>\n\n","protected":false},"excerpt":{"rendered":"<p>Validate model output instead of trusting the prompt: schema and rule checks, groundedness verification, classifier policy gates and PII redaction, plus fail-open vs fail-closed choices per surface.<\/p>\n","protected":false},"author":1,"featured_media":301,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[5,6,9,7],"class_list":["post-302","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ai-api","tag-ai-api","tag-api-gateway","tag-developer-tools","tag-developers"],"_links":{"self":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/302","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/comments?post=302"}],"version-history":[{"count":1,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/302\/revisions"}],"predecessor-version":[{"id":309,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/302\/revisions\/309"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/media\/301"}],"wp:attachment":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/media?parent=302"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/categories?post=302"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/tags?post=302"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}