方寸 Portal

AI Engineer Portal

Your personal operating system for career transition.

Private mode
Back to Learning Paths
Lesson

Content filtering and output validation

Input sanitization, output validation for toxicity and PII, NeMo Guardrails overview, and a pluggable OutputValidator implementation.

45 min
ai-safety-and-guardrailsphase-1portfolio

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

  1. Implement the OutputValidator class 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.
  2. Add a FormatCheck that 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 }.
  3. 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.
Lesson Deep Dive

Ask a follow-up question about this lesson and get an AI-powered explanation.

Loading previous questions...