Módulo 4: Guardrails — Input & Output Validation
2. Input Sanitization
Descripción
La sanitización de input es la primera capa del pipeline de guardrails. Normaliza y limpia el texto antes de enviarlo al LLM: elimina caracteres de control, normaliza espacios, aplica límites de longitud con conteo de tokens real, y valida encoding. Es la capa más barata de implementar (<1ms de latencia, $0) y previene una clase entera de problemas: ataques por volumen, caracteres maliciosos, y inconsistencias en el input.
Qué resuelve la sanitización
Sin sanitización, estos inputs llegan al LLM sin modificar:
# Input 1: Caracteres de control que confunden al tokenizer
"Analiza este texto\x00\x01\x08 y dame un resultado"
# El caracter NULL y otros control chars pueden causar comportamiento inesperado
# Input 2: Ataque por volumen (DoS económico)
"a" * 500_000 # ~125K tokens → ~$0.02 por request × 1000 requests = $20 en minutos
# Sin límite de longitud → un usuario malintencionado vacía tu cuenta
# Input 3: Encoding inválido que crashea el parser
b"texto\xff\xfe".decode("latin-1") # Bytes inválidos para UTF-8
# Puede causar UnicodeDecodeError en el procesamiento
# Input 4: Espacios múltiples que consumen tokens extra
"Este texto tiene muchos espacios"
# 5 tokens de palabras + 8 tokens de espacios = costo innecesario
Sanitizador completo con todas las capas
# src/guardrails/input_sanitizer.py
import re
import unicodedata
from typing import Optional
class InputSanitizationResult:
"""Resultado de la sanitización con metadata."""
def __init__(
self,
text: str,
was_truncated: bool = False,
original_length: int = 0,
had_control_chars: bool = False,
had_invalid_encoding: bool = False
):
self.text = text
self.was_truncated = was_truncated
self.original_length = original_length
self.had_control_chars = had_control_chars
self.had_invalid_encoding = had_invalid_encoding
def __bool__(self):
return bool(self.text)
@property
def was_modified(self) -> bool:
return self.was_truncated or self.had_control_chars or self.had_invalid_encoding
def sanitize_input(
text: str,
max_chars: int = 40_000,
normalize_spaces: bool = True,
strip_control_chars: bool = True,
fix_encoding: bool = True
) -> InputSanitizationResult:
"""
Sanitiza el input del usuario antes de enviarlo al LLM.
Pipeline:
1. Validar tipo y vacío
2. Fix encoding (si está habilitado)
3. Normalizar unicode (NFKC)
4. Eliminar caracteres de control
5. Normalizar espacios
6. Truncar por longitud máxima
Args:
text: El input del usuario
max_chars: Máximo de caracteres (default: 40K ≈ 10K tokens)
normalize_spaces: Colapsar múltiples espacios en uno
strip_control_chars: Eliminar caracteres de control ASCII
fix_encoding: Reparar bytes UTF-8 inválidos
Returns:
InputSanitizationResult con el texto limpio y metadata
"""
if not text or not isinstance(text, str):
return InputSanitizationResult(text="")
original_length = len(text)
had_control_chars = False
had_invalid_encoding = False
# Paso 1: Fix encoding (eliminar bytes inválidos UTF-8)
if fix_encoding:
encoded = text.encode("utf-8", errors="replace")
fixed = encoded.decode("utf-8", errors="replace")
if fixed != text:
had_invalid_encoding = True
text = fixed
# Paso 2: Normalizar Unicode a NFKC
# NFKC convierte: ① → 1, fi → fi, ½ → 1/2
# Útil para normalizar texto de fuentes variadas
text = unicodedata.normalize("NFKC", text)
# Paso 3: Eliminar caracteres de control ASCII
if strip_control_chars:
# Eliminar: NUL (0x00), BEL (0x07), BS (0x08), FF (0x0C), etc.
# MANTENER: TAB (0x09), LF (0x0A), CR (0x0D) — son saltos de línea válidos
original = text
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
if text != original:
had_control_chars = True
# Paso 4: Normalizar espacios
if normalize_spaces:
# Colapsar múltiples espacios en uno (pero no newlines)
text = re.sub(r' {2,}', ' ', text)
# Trim de espacios al inicio y fin
text = text.strip()
# Paso 5: Truncar por longitud máxima
was_truncated = False
if len(text) > max_chars:
text = text[:max_chars]
was_truncated = True
return InputSanitizationResult(
text=text,
was_truncated=was_truncated,
original_length=original_length,
had_control_chars=had_control_chars,
had_invalid_encoding=had_invalid_encoding
)
Contar tokens con tiktoken (límites precisos)
Los límites de caracteres son una aproximación. Para límites precisos, usa tiktoken:
# pip install tiktoken
import tiktoken
from functools import lru_cache
@lru_cache(maxsize=4)
def get_encoder(model: str = "gpt-4o-mini") -> tiktoken.Encoding:
"""Carga el encoder una vez y lo cachea."""
try:
return tiktoken.encoding_for_model(model)
except KeyError:
# Fallback al encoding estándar si el modelo no está en tiktoken
return tiktoken.get_encoding("cl100k_base")
def count_tokens(text: str, model: str = "gpt-4o-mini") -> int:
"""Cuenta los tokens exactos para un modelo específico."""
encoder = get_encoder(model)
return len(encoder.encode(text))
def truncate_to_tokens(
text: str,
max_tokens: int = 3_000,
model: str = "gpt-4o-mini"
) -> tuple[str, int]:
"""
Trunca texto para que no exceda max_tokens.
Returns:
(texto_truncado, tokens_usados)
"""
encoder = get_encoder(model)
tokens = encoder.encode(text)
if len(tokens) <= max_tokens:
return text, len(tokens)
truncated_tokens = tokens[:max_tokens]
truncated_text = encoder.decode(truncated_tokens)
return truncated_text, max_tokens
# Función de sanitización con límite por tokens:
def sanitize_with_token_limit(
text: str,
max_tokens: int = 3_000,
model: str = "gpt-4o-mini"
) -> InputSanitizationResult:
"""Sanitiza y luego trunca a un número específico de tokens."""
# Primero sanitizar (limpia el texto)
result = sanitize_input(text, max_chars=max_tokens * 6) # 6 chars/token como buffer
if not result.text:
return result
# Luego truncar exactamente por tokens
truncated, tokens_used = truncate_to_tokens(result.text, max_tokens, model)
if truncated != result.text:
result.text = truncated
result.was_truncated = True
return result
Reglas de longitud por modelo
# Límites máximos por modelo:
MODEL_CONTEXT_LIMITS = {
"gpt-4o-mini": 128_000, # tokens de contexto total
"gpt-4o": 128_000,
"gpt-4": 8_192,
"gpt-3.5-turbo": 16_385,
"claude-3-haiku": 200_000,
}
# Regla práctica para calcular max input tokens:
def calculate_max_input_tokens(
model: str,
system_prompt: str,
reserve_output_tokens: int = 500
) -> int:
"""
Calcula el máximo de tokens de input permitidos.
max_input = context_limit - system_prompt_tokens - output_reserve
"""
max_context = MODEL_CONTEXT_LIMITS.get(model, 8_192)
system_tokens = count_tokens(system_prompt, model)
return max_context - system_tokens - reserve_output_tokens
# Ejemplo real:
SYSTEM_PROMPT = "Eres un analizador de sentimiento. Responde con JSON."
MAX_INPUT = calculate_max_input_tokens("gpt-4o-mini", SYSTEM_PROMPT, 500)
# → 128_000 - 11 - 500 = 127_489 tokens disponibles para input
# → En práctica: usar 2_000-4_000 por razones de costo, no de límite
Límites por costo, no solo por capacidad
El contexto máximo no es el límite práctico — el costo lo es:
# Límite de contexto: 128K tokens
# Límite de costo para un endpoint /analyze:
# - Input: 3_000 tokens × $0.15/1M = $0.00045
# - Con 10K requests/día: $4.50/día ← OK
# Con input de 50K tokens:
# - Input: 50_000 tokens × $0.15/1M = $0.0075 por request
# - Con 10K requests/día: $75/día ← Puede ser demasiado caro
# Recomendación por tipo de endpoint:
ENDPOINT_TOKEN_LIMITS = {
"/analyze": 3_000, # Textos cortos de análisis
"/summarize": 8_000, # Documentos medianos
"/summarize-long": 32_000, # Documentos largos
"/chat": 4_000, # Conversación (por turno)
}
Tests completos del sanitizador
# tests/unit/guardrails/test_input_sanitizer.py
import pytest
from src.guardrails.input_sanitizer import sanitize_input, count_tokens
class TestSanitizeInput:
"""Tests del sanitizador de input."""
# ─── Happy path ───────────────────────────────────────────────
def test_normal_text_unchanged(self):
result = sanitize_input("Hola, ¿cómo estás?")
assert result.text == "Hola, ¿cómo estás?"
assert not result.was_modified
def test_trims_leading_trailing_spaces(self):
result = sanitize_input(" texto con espacios ")
assert result.text == "texto con espacios"
# ─── Control characters ───────────────────────────────────────
def test_removes_null_bytes(self):
result = sanitize_input("hello\x00world")
assert "\x00" not in result.text
assert result.had_control_chars
def test_removes_control_chars(self):
malicious = "texto\x01\x02\x03normal"
result = sanitize_input(malicious)
assert "\x01" not in result.text
assert "\x02" not in result.text
assert "textonormal" == result.text
def test_preserves_newlines(self):
"""Los saltos de línea son válidos y deben preservarse."""
text = "línea 1\nlínea 2\r\nlínea 3"
result = sanitize_input(text)
assert "\n" in result.text # LF se preserva
def test_preserves_tabs(self):
text = "columna1\tcolumna2"
result = sanitize_input(text)
assert "\t" in result.text
# ─── Normalización de espacios ────────────────────────────────
def test_collapses_multiple_spaces(self):
result = sanitize_input("texto con muchos espacios")
assert result.text == "texto con muchos espacios"
# ─── Length limits ────────────────────────────────────────────
def test_truncates_at_max_chars(self):
long_text = "a" * 100_000
result = sanitize_input(long_text, max_chars=1_000)
assert len(result.text) == 1_000
assert result.was_truncated
def test_short_text_not_truncated(self):
result = sanitize_input("texto corto", max_chars=1_000)
assert not result.was_truncated
# ─── Empty / None ─────────────────────────────────────────────
def test_empty_string_returns_empty(self):
result = sanitize_input("")
assert result.text == ""
assert not bool(result)
def test_whitespace_only_returns_empty(self):
result = sanitize_input(" \t\n ")
assert result.text == ""
def test_none_returns_empty(self):
result = sanitize_input(None)
assert result.text == ""
# ─── Unicode ──────────────────────────────────────────────────
def test_preserves_spanish_chars(self):
text = "Comunicación en español con ñ, tildes: á é í ó ú"
result = sanitize_input(text)
assert "ñ" in result.text
assert "á" in result.text
def test_preserves_emojis(self):
text = "Me encanta 😊 este producto 🎉"
result = sanitize_input(text)
assert "😊" in result.text
def test_normalizes_unicode_nfkc(self):
"""Caracteres Unicode equivalentes se normalizan a forma canónica."""
# ① (circled digit one) → 1
text = "Punto \u2460 importante"
result = sanitize_input(text)
assert "1" in result.text # Normalizado a "1"
class TestCountTokens:
def test_approximate_token_count(self):
"""Una palabra corta ≈ 1 token."""
assert 1 <= count_tokens("hello") <= 3
def test_long_text_has_more_tokens(self):
short = count_tokens("hi")
long = count_tokens("hello world this is a long sentence with many words")
assert long > short
Integración con FastAPI
# src/app/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from src.guardrails.input_sanitizer import sanitize_with_token_limit
app = FastAPI()
class AnalyzeRequest(BaseModel):
text: str = Field(min_length=1, max_length=200_000) # Pre-validación básica de Pydantic
@app.post("/analyze")
async def analyze_endpoint(request: AnalyzeRequest):
# Sanitizar antes de pasar al LLM
sanitized = sanitize_with_token_limit(request.text, max_tokens=3_000)
if not sanitized:
raise HTTPException(status_code=400, detail="Input vacío o inválido")
if sanitized.was_truncated:
# Opcionalmente: notificar al usuario que se truncó
# O simplemente proceder sin avisar (depende del UX)
pass
result = analyze_sentiment(sanitized.text)
return result
Ejercicios
Ejercicio 1: Implementar sanitize_input minimalista
Implementa una versión de sanitize_input que solo haga: trim, colapsar espacios, y limitar a 5000 caracteres:
Ver solución
def sanitize_input(text: str, max_chars: int = 5000) -> str:
if not text or not isinstance(text, str):
return ""
# Trim + colapsar espacios
cleaned = " ".join(text.strip().split())
# Límite de longitud
return cleaned[:max_chars]
# Tests básicos:
assert sanitize_input(" hola mundo ") == "hola mundo"
assert sanitize_input("") == ""
assert len(sanitize_input("a" * 10000)) == 5000
Ejercicio 2: Test de caracteres de control
Escribe 5 tests para el filtrado de caracteres de control. Incluye: NULL byte, BEL, texto mezclado, preservar newlines, preservar tabs:
Ver solución
def test_null_byte_removed():
assert "\x00" not in sanitize_input("texto\x00")
def test_bel_removed():
assert "\x07" not in sanitize_input("texto\x07normal")
def test_mixed_control_and_normal():
result = sanitize_input("hola\x01mundo")
assert result == "holamundo" # Control char eliminado, sin espacio
def test_newline_preserved():
assert "\n" in sanitize_input("línea1\nlínea2")
def test_tab_preserved():
assert "\t" in sanitize_input("col1\tcol2")
Ejercicio 3: Calcular el límite de tokens para tu app
Tu app tiene:
- Modelo: gpt-4o-mini
- System prompt: "Eres un analizador de sentimiento. Responde SOLO con JSON."
- Output máximo: 200 tokens
¿Cuántos tokens puedes usar para el input del usuario? ¿En caracteres aproximados?
Ver cálculo
# Contar tokens del system prompt:
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o-mini")
system_tokens = len(enc.encode("Eres un analizador de sentimiento. Responde SOLO con JSON."))
# ≈ 12 tokens
# Límite de contexto gpt-4o-mini: 128,000 tokens
# max_input = 128,000 - 12 (system) - 200 (output) = 127,788 tokens disponibles
# En práctica (por costo):
# 3,000 tokens por request × $0.15/1M = $0.00045 por request
# Con 100K requests/día: $45/día — aceptable
# En caracteres aprox (4 chars/token para español):
# 3,000 tokens × 4 = 12,000 caracteres → max_chars = 12_000
Ejercicio 4: Sanitización para diferentes tipos de input
Para cada tipo de input, decide qué nivel de sanitización necesitas:
- Chat text libre del usuario
- Código Python enviado por el usuario
- Documento PDF parseado (texto extraído)
- URL enviada por el usuario
Ver guía
-
Chat text libre: Sanitización completa — trim, normalizar espacios, control chars, length limit. El texto puede venir de cualquier dispositivo.
-
Código Python: CUIDADO — normalizar espacios rompería el código. Solo: trim, control chars peligrosos (NULL), length limit. NO colapsar espacios (la indentación importa).
-
Documento PDF: Sanitización completa. Los PDFs extraídos contienen muchos artefactos: caracteres extraños, espacios dobles, etc.
-
URL: Validar que es una URL válida con
urllib.parse.urlparse. Length limit estricto (URLs > 2000 chars son sospechosas). No normalizar espacios (las URLs no deben tenerlos).
Ejercicio 5: Logging de sanitización
Implementa el logging de cuando se sanitiza un input modificado:
Ver solución
import logging
import hashlib
logger = logging.getLogger("guardrails.sanitizer")
def sanitize_and_log(text: str, user_id: str = None) -> InputSanitizationResult:
result = sanitize_input(text)
if result.was_modified:
# Loguear sin incluir el contenido original (privacy)
input_hash = hashlib.sha256(text.encode()).hexdigest()[:8]
logger.info(
"input_sanitized",
extra={
"input_hash": input_hash,
"original_length": result.original_length,
"cleaned_length": len(result.text),
"was_truncated": result.was_truncated,
"had_control_chars": result.had_control_chars,
"user_id": user_id
}
)
return result
Resumen
- Sanitización = primera capa del pipeline: normalizar, limpiar, limitar longitud
- Pipeline de 5 pasos: fix encoding → normalizar unicode → eliminar control chars → colapsar espacios → truncar
- Límite por tokens es más preciso que por caracteres — usar tiktoken para apps de producción
- Límite práctico es de costo, no de capacidad técnica — definir según tu presupuesto por request
- Tests obligatorios: NULL bytes, texto vacío, texto muy largo, caracteres especiales válidos (ñ, emojis)
- Ser conservador: mejor no eliminar caracteres válidos que eliminar demasiado
Recursos adicionales
- tiktoken — Tokenizer oficial de OpenAI para contar tokens exactos
- OpenAI Tokenizer (web) — Visualizar tokens interactivamente
- Unicode NFKC normalization — Por qué normalizar unicode
- OWASP Input Validation Cheat Sheet — Guía completa de validación
- Python unicodedata — Módulo estándar para unicode
- Python re module — Para los patrones de control chars