Saltar al contenido principal
advancedPart 4

Rastros limpios, respuestas intactas: enmascarando PII en los registros de LiteLLM sin corromper la respuesta

· 16 min de lectura
Rafael Fernandes
Ingeniero de PLN y Redactor Técnico en WiLine
Share:
LiteLLM+Presidio+Langfuse
0/5
🎯 Skill path0/5 earned
Self-hosting an LLM gateway

La Parte 3 configuró Presidio para enmascarar los prompts en el momento del registro y deliberadamente se detuvo antes de probarlo. Aún no se había conectado a un destino de registro, así que no había rastro que inspeccionar.

Esta publicación lo conecta, encuentra <PERSON> donde debería estar — y luego encuentra la dirección de correo electrónico real del cliente sentada unas líneas más abajo, en la respuesta del modelo.

Todo en esta publicación sigue desde donde se encuentran esos dos hooks. post_call se activa antes de la flecha de regreso al llamador, así que enmascarar allí reescribe la respuesta que reciben. async_logging_hook se activa después de eso, solo en la rama hacia el registrador.

Enviando el tráfico del gateway a Langfuse

Langfuse ya está funcionando en esta serie; si seguiste el tutorial de observabilidad tienes una instancia. Crea un proyecto para el gateway, luego toma sus dos claves de Settings → API Keys.

La pantalla de configuración del proyecto de Langfuse mostrando las claves secreta y pública

Figura 1. Un proyecto separado mantiene el tráfico del gateway alejado de cualquier otra cosa que estés rastreando.

Agrega tres variables al .env del gateway:

LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_HOST=http://your-host:3001
El nombre de la variable no es el que Langfuse te da

La propia pantalla de configuración de Langfuse imprime un bloque .env que contiene LANGFUSE_BASE_URL. El gateway no lee eso. Lee:

os.getenv("LANGFUSE_HOST", "https://cloud.langfuse.com")

Copia su fragmento tal cual y tu host será ignorado a favor del endpoint de la nube pública — donde tus claves autoalojadas no se autenticarán. Los rastros no van a ninguna parte, y no hay errores.

Luego activa el callback:

config.yaml
litellm_settings:
drop_params: true
success_callback: ["langfuse"]
failure_callback: ["langfuse"]

Reiniciar no es suficiente

docker compose restart litellm

Envía una solicitud después de eso y los registros dicen:

Output
El cliente de Langfuse está deshabilitado ya que no se proporcionó public_key como parámetro
o variable de entorno 'LANGFUSE_PUBLIC_KEY'.

Las claves están en .env. No están en el contenedor:

docker exec llm-gateway printenv | grep -c LANGFUSE
Output
0
restart no recarga env_file

docker compose restart reinicia el proceso dentro del contenedor que ya existe, con el entorno con el que fue creado. Las nuevas variables en .env no se recogen.

El cambio en config.yaml tuvo efecto, porque ese es un archivo montado que se lee al inicio. Así que el gateway parece estar configurado correctamente — callbacks inicializados, sin errores — mientras no tiene credenciales en absoluto.

Usa docker compose up -d, que recrea el contenedor.

docker compose up -d
docker exec llm-gateway printenv | grep -c LANGFUSE
Output
3

printenv dentro del contenedor ahora reportando tres variables LANGFUSE

Figura 2. Después de up -d, las variables están en el contenedor.

El rastro llega, y el ciclo se cierra

Espera a que Application startup complete, luego envía un prompt que lleva un nombre y una dirección:

source .env && curl -s http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"qwen-small","messages":[{"role":"user","content":"¿Cuál es el primer nombre de Maria Alvarez, y cuál es el dominio de [email protected]? Responde en una línea corta."}],"max_tokens":40}' \
| jq -r '.choices[0].message.content'
Output
El primer nombre de Maria Alvarez es Maria; el dominio de [email protected] es example.com.

Esa respuesta es el control. El modelo no podría haber producido "Maria" de un placeholder, así que la solicitud en vivo llegó a él sin enmascarar — exactamente lo que promete logging_only.

Ahora el rastro:

Un rastro de Langfuse mostrando la entrada enmascarada sobre la respuesta sin enmascarar del asistente

Figura 3. Entrada enmascarada. Salida no.

Stored input
¿Cuál es el primer nombre de <PERSON>, y cuál es el dominio de <EMAIL_ADDRESS>?
Stored output
El primer nombre de Maria Alvarez es Maria; el dominio de [email protected] es example.com.

La tarea de la Parte 3 está hecha — el prompt está enmascarado en el registro mientras el modelo vio el texto real. Y la PII está en el rastro de todos modos, porque el modelo la repitió.

Eso no es un caso extremo. Repetir el nombre del cliente es para lo que sirve un asistente de soporte.

El registro de gastos es peor

La propia base de datos del gateway almacena la misma solicitud de manera diferente:

curl -s http://127.0.0.1:4000/spend/logs \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" | jq '.[0] | {messages, response}'
Output
"messages": {}
"response": { ... "content": "El primer nombre de Maria Alvarez es Maria;
el dominio de [email protected] es example.com." ... }

Sin prompt en absoluto, y la finalización en su totalidad. Dos almacenes, dos formas, el mismo hueco en ambos.

Ampliar el alcance intercambia un problema por otro

La Parte 3 estableció presidio_filter_scope: input. La solución obvia es escanear en ambas direcciones:

config.yaml
presidio_filter_scope: both

Reinicia, envía la solicitud idéntica y lee la respuesta que recibe el llamador:

Output
El primer nombre de <PERSON> es <PERSON>; el dominio de <EMAIL_ADDRESS> es <URL>.

La respuesta de curl que contiene placeholders en lugar del nombre y la dirección reales

Figura 4. La propia terminal del llamador, no un visor de registros.

Eso no es el registro. Esa es la respuesta devuelta al cliente.

mode: logging_only sigue configurado. No ayudó, porque nunca llega a la ruta de salida — la documentación para la configuración del alcance lo dice directamente:

Usa presidio_filter_scope: output (o both) cuando quieras que Presidio escanee y enmascare activamente la respuesta del modelo antes de que llegue al usuario.

Enmascarar la respuesta en vivo es el propósito documentado de un alcance de salida. Lo que no está documentado es la combinación: nada describe lo que sucede cuando configuras logging_only y un alcance de salida, y el código lo resuelve a favor de la ruta en vivo.

En guardrail_initializers.py:

if run_output:
output_callback = _make_presidio_callback(
apply_to_output=True,
event_hook=GuardrailEventHooks.post_call.value, # codificado
output_parse_pii=False,
)

El mode que configuraste se pasa al callback de entrada. El callback de salida recibe post_call independientemente.

Verifica que el modelo aún reciba datos reales

Una respuesta enmascarada tiene dos posibles causas: la respuesta fue enmascarada en el camino de salida, o el modelo recibió placeholders y respondió honestamente sobre ellos. Se ven idénticos.

Pide algo que codifique el valor real sin contenerlo — una sola letra no es PII, así que enmascarar no puede ocultarlo:

curl ... -d '{"model":"qwen-small","messages":[{"role":"user","content":"Responde SOLO con la primera letra del primer nombre de Maria Alvarez."}],"max_tokens":200}'
Output

M. El modelo tenía el nombre real. Solo se reescribió la respuesta.

Así que las dos configuraciones te dan una elección, y ninguna es la que quieres:

Entrada registradaSalida registradaRespuesta del llamador
filter_scope: inputenmascaradaPII sin procesarintacta
filter_scope: bothenmascaradaenmascaradaenmascarada

Esto ha sido reportado. Issue #30447"logging_only El guardrail de Presidio corrompe la respuesta orientada al usuario" — fue presentado el 15 de junio de 2026 y cerrado al día siguiente, sin discusión, junto con una solicitud de extracción titulada "fix(presidio): no enmascarar la solicitud en vivo cuando el guardrail está en logging_only". El informe era sobre la respuesta. La solución abordó la solicitud. #35951 sigue abierta en un camino relacionado.

Haciéndolo en el momento del registro en su lugar

La brecha existe porque post_call se ejecuta mientras la respuesta aún viaja hacia el llamador. Hay un hook posterior que no lo hace.

async_logging_hook se activa después de que el modelo ha respondido y antes de que se ejecuten los registradores. Recibe la solicitud y el resultado, y lo que devuelve es lo que se registra. El llamador ya tiene su respuesta para entonces.

Ese hook está documentado, bajo Scrub Logged Data — pero el ejemplo allí es un placeholder que reemplaza cada mensaje con la cadena literal MASK_THIS_ASYNC_VALUE. Muestra que el hook existe; no enmascara nada, solo toca kwargs["messages"], y muta en su lugar. Lo que sigue utiliza el mismo punto de extensión y agrega las partes que lo hacen funcionar: llamadas reales a Presidio, los tres lugares donde se oculta la PII, y una copia para que la respuesta del llamador sobreviva.

Su ejemplo no se ejecuta como está escrito

El fragmento en esa página termina con return kwargs, responses, pero el parámetro se llama result. responses no está definido, así que copiarlo tal cual genera un NameError.

Crea scrubber.py junto a config.yaml:

scrubber.py
import copy
import os
from typing import Any, Tuple

import httpx
from litellm.integrations.custom_logger import CustomLogger

ANALYZER = os.getenv("PRESIDIO_ANALYZER_API_BASE", "http://presidio-analyzer:3000")
ANONYMIZER = os.getenv("PRESIDIO_ANONYMIZER_API_BASE", "http://presidio-anonymizer:3000")


async def _mask(text: str) -> str:
if not text or not text.strip():
return text
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{ANALYZER}/analyze", json={"text": text, "language": "en"})
found = r.json()
if not found:
return text
r = await client.post(
f"{ANONYMIZER}/anonymize",
json={"text": text, "analyzer_results": found},
)
return r.json()["text"]


class PresidioLogScrubber(CustomLogger):
async def async_logging_hook(
self, kwargs: dict, result: Any, call_type: str
) -> Tuple[dict, Any]:
if call_type not in ("completion", "acompletion"):
return kwargs, result

kwargs = copy.deepcopy(kwargs)

for message in kwargs.get("messages") or []:
if isinstance(message.get("content"), str):
message["content"] = await _mask(message["content"])

slo = kwargs.get("standard_logging_object")
if isinstance(slo, dict):
for message in slo.get("messages") or []:
if isinstance(message, dict) and isinstance(message.get("content"), str):
message["content"] = await _mask(message["content"])
response = slo.get("response")
if isinstance(response, dict):
for choice in response.get("choices") or []:
message = (choice or {}).get("message") or {}
if isinstance(message.get("content"), str):
message["content"] = await _mask(message["content"])

# `result` es el objeto que ya tiene el llamador. Copiar antes de enmascarar.
logged_result = copy.deepcopy(result)
for choice in getattr(logged_result, "choices", None) or []:
message = getattr(choice, "message", None)
if message is not None and isinstance(getattr(message, "content", None), str):
message.content = await _mask(message.content)

return kwargs, logged_result


instance = PresidioLogScrubber()

Tres detalles llevan todo esto.

La PII se oculta en tres lugares, no en uno. kwargs["messages"] es el prompt. kwargs["standard_logging_object"] es lo que las integraciones de registro realmente leen — este es el tema del issue #35951. Y result contiene la finalización. Perder cualquiera de ellos significa que algo se filtra.

result es el objeto del llamador. Mutarlo en su lugar reproduce el error que estamos evitando. Se copia profundamente primero, y la copia es lo que enmascaramos y devolvemos.

_mask es async a propósito. Más sobre eso a continuación, porque hacerlo mal cuesta latencia medible.

Monta el archivo y regístralo. Debe estar al lado de config.yaml, porque get_instance_fn resuelve la ruta con puntos relativa al directorio del archivo de configuración:

docker-compose.yml
volumes:
- ./config.yaml:/app/config.yaml:ro
- ./scrubber.py:/app/scrubber.py:ro
config.yaml
litellm_settings:
drop_params: true
callbacks: ["scrubber.instance"]
success_callback: ["langfuse"]

Establece el guardrail incorporado en default_on: false mientras pruebas. Si tanto él como el callback están enmascarando, no puedes decir cuál hizo el trabajo.

Agregar un volumen cambia la definición del contenedor, así que esto necesita una recreación, no un reinicio:

docker compose up -d

Probándolo, en ambos lados de una solicitud

Envía una solicitud y guarda el cuerpo de la respuesta:

curl -s http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"qwen-small","messages":[{"role":"user","content":"¿Quién es Priya Raghunathan y cuál es el dominio de [email protected]? Una línea corta."}],"max_tokens":40}' \
-o response.json

Lo que recibió el llamador:

jq -r '.choices[0].message.content' response.json
Output
Priya Raghunathan es una investigadora de IA, y el dominio
[email protected] es probablemente una dirección de correo electrónico personal.

Lo que almacenó Langfuse:

curl -s -u "$LANGFUSE_PUBLIC_KEY:$LANGFUSE_SECRET_KEY" \
"http://your-host:3001/api/public/traces?limit=1" -o trace.json
jq -r '.data[0].output.content' trace.json
Output
<PERSON> es una investigadora de IA, y el dominio <EMAIL_ADDRESS> es probablemente una
dirección de correo electrónico personal.

La misma oración dos veces — la respuesta con valores reales, el rastro con placeholders

Figura 5. La misma oración. Una real, una enmascarada.

El rastro de Langfuse con la entrada y salida enmascaradas, junto a la respuesta sin enmascarar

Figura 6. Enmascarado en ambos lados del rastro, intacto para el llamador.

Leer una UI renderizada prueba menos que verificar las cargas útiles, así que verifícalas:

grep -c "Priya Raghunathan" response.json
grep -c "Priya Raghunathan" trace.json
grep -o "PERSON" trace.json | wc -l
grep -c "Una línea corta" trace.json
Output
1
0
2
1

Cuatro conteos de grep que prueban que la respuesta contiene el nombre real y el rastro no

Figura 7. El recibo: 1 0 2 1.

El nombre real está en lo que recibió el llamador. No está en el rastro. El placeholder aparece dos veces — entrada y salida. Y la última línea prueba que ambos archivos describen la misma solicitud, lo que los primeros tres no establecen por sí solos.

Habilidad desbloqueada 🏅

Puedes enmascarar lo que un gateway registra sin cambiar lo que devuelve, y probarlo inspeccionando las cargas útiles en ambos lados de una sola solicitud.

La versión que te cuesta silenciosamente 200-350ms

La primera versión funcional de _mask usó un cliente bloqueante — httpx.Client dentro de un async def. Enmascaró correctamente. También hizo esto:

ejecución 1ejecución 2
sin callback0.74 s
bloqueando httpx.Client1.10 s0.97 s
async httpx.AsyncClient0.77 s0.77 s

Medianas cálidas, cuatro solicitudes secuenciales cada una, la primera descartada como fría. La figura asíncrona se repitió exactamente; la bloqueante no, que es el punto — la penalización depende de lo que más esté haciendo el bucle.

Ejecuciones cronometradas con el cliente bloqueante y con el cliente asíncrono

Figura 8. La misma solicitud, el mismo enmascaramiento, una palabra diferente en el código.

Una llamada HTTP bloqueante dentro de un hook asíncrono detiene el bucle de eventos hasta que Presidio responde, y cada solicitud hace varias de tales llamadas. El enmascaramiento no está en la ruta crítica del llamador — pero está en la de todos los demás, porque nada más puede ser atendido mientras espera.

Cambiar httpx.Client a httpx.AsyncClient y esperar las llamadas elimina el costo: 0.77s contra una línea base de 0.74s, dentro del ruido de la llamada del modelo en sí, y se reprodujo hasta el centésimo en dos ejecuciones separadas.

Lo que no probamos

Estos números son cuatro solicitudes secuenciales. Bloquear el bucle de eventos apenas se muestra en una solicitud a la vez — es bajo concurrencia que las dos versiones divergen, y no hemos medido eso aquí. Si ejecutas esto a volumen, pruébalo bajo carga antes de confiar en la tabla anterior.

Cada solicitud también significa dos llamadas HTTP a Presidio por mensaje más dos para la finalización. A volumen real, Presidio se convierte en un servicio que dimensionas y monitoreas, no un detalle de fondo.

Dónde te deja esto

Entrada registradaSalida registradaRespuesta del llamadorCosto
filter_scope: inputenmascaradaPII sin procesarintactaninguno
filter_scope: bothenmascaradaenmascaradaenmascarada0.30s de duración del guardrail
hook de registroenmascaradaenmascaradaintactaninguno medible

Vale la pena ser claro sobre el límite de lo que esto logra: la PII aún viaja. Llega al modelo y llega al llamador. Lo que se ha eliminado es retención — ya no está sentada en un almacén de rastros que un grupo más amplio de personas puede leer, meses después, mucho después de que la solicitud misma se haya ido.

Verificado contra ghcr.io/berriai/litellm:main-stable, UI de administración reportando v1.96.2, el 21 de agosto de 2026. Si una versión posterior agrega un modo de salida con alcance de registro, prefierelo sobre esto.

Solución de problemas

Los rastros nunca aparecen y nada da error

Verifica que LANGFUSE_HOST esté configurado, no LANGFUSE_BASE_URL, y confirma que las variables estén dentro del contenedor con docker exec llm-gateway printenv | grep LANGFUSE. Un docker compose restart no las habrá cargado.

El callback no se ejecuta y el gateway inicia normalmente

Una mala ruta con puntos falla silenciosamente. scrubber.py debe estar en el mismo directorio que config.yaml — dentro del contenedor, no solo en el host — y el nombre del objeto después del punto debe existir. Busca en el registro de inicio ImportError y AttributeError.

La respuesta vuelve enmascarada

La copia profunda falta, o el guardrail incorporado sigue habilitado con un alcance de salida. Establece default_on: false en él y confirma con curl /guardrails/list.

Todo se volvió más lento

Verifica que _mask use httpx.AsyncClient con await, no httpx.Client. Consulta las mediciones anteriores.

Finished this tutorial?
Mark it complete to earn Clean traces, untouched answers on your skill path.

Qué sigue

Las mediciones aquí son secuenciales, y el modo de falla que costó 200-350ms es uno que solo afecta adecuadamente bajo concurrencia. La próxima publicación pone el gateway bajo carga paralela y mide lo que realmente sucede con la latencia cuando varias solicitudes compiten por el mismo bucle de eventos.

Lectura adicional

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.