Why this matters
LLMs can produce outputs your users should never see: toxic language, leaked personally identifiable information, responses that are wildly off-topic for your application, or structured data in the wrong format. Some of these failures are accidental (the model drifted). Some are adversarially induced. Either way, they reach users if you have no output layer.
Input sanitization and output validation are the before and after gates on your LLM call. Neither is optional in production.
Core concepts
Input sanitization
Sanitization is not censorship — it is normalization and risk reduction before the prompt is constructed:
import re
import html
def sanitize_input(raw: str, max_length: int = 4000) -> str:
# Truncate to prevent prompt stuffing
text = raw[:max_length]
# Decode HTML entities (attackers use <script> etc.)
text = html.unescape(text)
# Normalize Unicode to prevent homoglyph attacks
import unicodedata
text = unicodedata.normalize("NFKC", text)
# Strip null bytes and control characters except newlines/tabs
text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
return text.strip()
Sanitization happens before threat classification, and both happen before the prompt is assembled.
Output validation pipeline
Output validation runs after the LLM returns a response, before it reaches the user. A validator pipeline applies multiple independent checks:
from dataclasses import dataclass, field
from typing import Protocol
@dataclass
class ValidationResult:
passed: bool
failed_checks: list[str] = field(default_factory=list)
sanitized_output: str = ""
class OutputCheck(Protocol):
def check(self, text: str) -> tuple[bool, str]:
# Returns (passed, reason_if_failed)
...
class LengthCheck:
def __init__(self, max_chars: int = 8000) -> None:
self.max_chars = max_chars
def check(self, text: str) -> tuple[bool, str]:
if len(text) > self.max_chars:
return False, f"Output exceeds {self.max_chars} chars ({len(text)} actual)"
return True, ""
class PIIPresenceCheck:
PATTERNS = {
"email": re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"),
"ssn": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
"credit_card": re.compile(r"\b(?:\d[ -]?){13,16}\b"),
"phone": re.compile(r"\b(\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b"),
}
def check(self, text: str) -> tuple[bool, str]:
for pii_type, pattern in self.PATTERNS.items():
if pattern.search(text):
return False, f"Output contains potential {pii_type}"
return True, ""
class ToxicityPatternCheck:
# In production this would call a toxicity classification model.
# For now, a basic keyword list demonstrates the interface.
TOXIC_PATTERNS = re.compile(
r"\b(explicit_slur_1|explicit_slur_2)\b", # replace with real patterns
re.IGNORECASE,
)
def check(self, text: str) -> tuple[bool, str]:
if self.TOXIC_PATTERNS.search(text):
return False, "Output contains toxicity marker"
return True, ""
class OutputValidator:
def __init__(self, checks: list[OutputCheck]) -> None:
self.checks = checks
def validate(self, text: str) -> ValidationResult:
failed = []
for check in self.checks:
passed, reason = check.check(text)
if not passed:
failed.append(reason)
return ValidationResult(
passed=len(failed) == 0,
failed_checks=failed,
sanitized_output=text if len(failed) == 0 else "",
)
# Usage
validator = OutputValidator(checks=[
LengthCheck(max_chars=6000),
PIIPresenceCheck(),
ToxicityPatternCheck(),
])
result = validator.validate(llm_response_text)
if not result.passed:
# Return safe fallback, log the failure
print(f"Output validation failed: {result.failed_checks}")
NeMo Guardrails: when to use a framework
NVIDIA's NeMo Guardrails provides a declarative way to define rails in a domain-specific language (Colang) with off-the-shelf topical, safety, and factual checks.
Use NeMo Guardrails when:
- You need validated, pre-built topical rails with real toxicity models (not keyword lists)
- Your application has complex multi-turn dialog state
- Your team wants rails defined in a configuration file rather than Python
Roll your own when:
- You need to audit every check in your codebase (compliance requirement)
- Your validation logic is tightly coupled to your data models
- You need minimal dependency footprint in a constrained environment
For most production systems: start with roll-your-own for input sanitization and PII checks (deterministic, testable), and consider NeMo Guardrails if you need topical rail or toxicity classification without building your own model.
Common mistakes
Validation that modifies output without flagging it. If your validator silently removes PII and returns the cleaned text, you lose the signal that PII appeared in the first place. Log every validation failure, even if you handle it gracefully.
Checks that are too slow. Every check adds latency to your response path. Keep synchronous checks fast (regex, length). Offload ML-based toxicity scoring to async or a background queue if latency is critical.
Treating validation as a one-time setup. The types of content your LLM produces drift as you update prompts, models, and retrieval corpora. Re-evaluate your validation suite whenever you make a significant change.
Not testing the validator against adversarial outputs. Build a test fixture of known-bad outputs (PII examples, toxic patterns, overlong responses) and run it against your validator on every deploy.
Try it yourself
- Implement the
OutputValidatorclass with all three checks above. Write a pytest test with four cases: one clean output (should pass), one with a fake email address (PIIPresenceCheck fails), one over 8000 characters (LengthCheck fails), and one that fails both PII and length. - Add a
FormatCheckthat accepts a regex pattern and verifies the output matches it. Use it to validate that a response formatted as JSON actually starts with{and ends with}. - Extend the pipeline to return a safe fallback string when validation fails, and log the failure reason with the input hash (not the full input) for privacy-safe debugging.