Multitenancy en Agentes IA: Contexto Aislado por Cliente
El agente funciona perfecto en demo. Lo despliegas para el primer cliente, va bien. Añades el segundo. El tercero.
Y entonces ocurre algo que no esperabas: el agente del Cliente B responde con contexto del Cliente A. O peor: no lo notas hasta que el Cliente B te llama para decirte que su asistente IA maneja información que no le pertenece.
Eso no es un bug del modelo. Es un bug de arquitectura. Y es más común de lo que debería.
El error que nadie documenta hasta que ocurre
La mayoría de sistemas multi-tenant nacen para un solo cliente y crecen sobre esa base sin repensar la arquitectura. El system prompt se parametriza, el vector store se reutiliza, la memoria se comparte, y todo parece funcionar.
Hasta que no funciona.
Hay tres patrones de fallo que aparecen sistemáticamente en producción.
Vector store compartido sin filtro de tenant
Un único índice en Pinecone, Weaviate o pgvector para todos los clientes. Las queries van al mismo espacio de embeddings y el LLM recibe fragmentos de cualquier cliente.
En producción esto produce respuestas donde el contexto pertenece a otro tenant. No siempre. A veces. Que es lo peor: parece que funciona hasta que en el peor momento posible deja de funcionar, y entonces no sabes desde cuándo lleva fallando.
System prompt monolítico con variables
Un único template con {company_name}, {tone}, {products}. El problema no es el template — es que no puede capturar la semántica de comportamiento específico de cada cliente. El agente del despacho de abogados acaba pareciéndose demasiado al de la clínica o al de la tienda de ropa.
Memoria stateful sin scope por tenant
Si usas memoria persistente — Redis, Supabase, cualquier base de datos — cada fragmento de memoria necesita un tenant_id. Sin ese campo, el agente puede recuperar contexto de conversaciones de otro cliente cuando hace búsqueda semántica sobre la memoria.
Patrones de arquitectura que sí funcionan
Tenant ID como ciudadano de primera clase
No lo añadas al final como un patch. Diseña desde el principio que cada request lleva un tenant_id verificado, que cada escritura en base de datos incluye ese campo, y que cada query filtra por él.
async function handleAgentRequest(tenantId: string, userMessage: string) {
// 1. Cargar configuración por tenant
const config = await tenantConfigRepo.findOrThrow(tenantId)
// 2. Recuperar memoria con scope aislado
const memories = await memoryStore.query({
tenantId, // siempre presente
query: userMessage,
topK: 5,
})
// 3. Construir contexto con el system prompt del tenant
const response = await llm.chat({
system: buildSystemPrompt(config),
messages: [...memories.map(toMessage), { role: 'user', content: userMessage }],
})
// 4. Guardar con tenant_id en la escritura
await memoryStore.save({
tenantId,
userMessage,
assistantResponse: response.content,
timestamp: Date.now(),
})
return response
}
El tenantId viaja por toda la cadena: config loading, vector search, memory read/write, audit log. Si en algún punto de esa cadena puede ejecutarse sin tenantId, hay una fuga potencial.
Vector store: colección por tenant o metadata filter
Dos opciones con tradeoffs reales:
Colección separada por tenant (Pinecone namespace, Weaviate class, pgvector schema separado):
- Aislamiento perfecto, imposible leakage entre tenants
- Coste más alto cuando escalan los clientes
- Operaciones de mantenimiento más complejas (reindexar, migrar)
- Recomendado para datos sensibles o clientes enterprise con SLA de privacidad
Índice compartido con filtro por metadata:
const results = await vectorStore.similaritySearch(userQuery, {
filter: { tenant_id: { $eq: tenantId } },
k: 5,
})
// CRÍTICO: verificar que results solo contiene documentos del tenant correcto
if (results.some(r => r.metadata.tenant_id !== tenantId)) {
throw new Error(`Context leak detected for tenant ${tenantId}`)
}
Esta opción es más barata a escala pero depende de que el filtro funcione correctamente en el vector database que uses. Añade siempre la verificación posterior — si el filtro falla por un bug de implementación o un update de la librería, quieres detectarlo antes que tu cliente.
Config loading: un objeto completo por tenant, no un template
En lugar de interpolar variables en un string, carga un objeto de configuración completo desde base de datos:
interface TenantConfig {
systemPrompt: string
allowedTools: ToolName[]
escalationRules: EscalationRule[]
model: 'claude-haiku-4-5-20251001' | 'claude-sonnet-5' | 'claude-opus-5'
maxTokensPerRequest: number
language: 'es' | 'en' | 'fr'
}
const config = await tenantConfigRepo.findOrThrow(tenantId)
Esto permite que cada cliente tenga herramientas distintas, reglas de escalación distintas, incluso modelos distintos según su plan. El sistema deja de asumir que todos los clientes son iguales — porque no lo son.
Lo que no detectas sin tests de aislamiento
La leakage de contexto entre tenants es silenciosa. No genera un error 500. No aparece en los logs de error del servidor. El agente responde, el request llega con 200, y nadie nota que la respuesta contiene información que no debería estar ahí.
Por eso necesitas tests de aislamiento específicos en tu suite de integration tests:
describe('Tenant isolation', () => {
it('Tenant B cannot see Tenant A documents', async () => {
// Indexar documento con dato único para Tenant A
await vectorStore.upsert([{
id: 'doc_secret_a',
content: 'CONFIDENTIAL_MARKER_TENANT_A_XK9',
metadata: { tenant_id: 'tenant_a' },
}])
// Query desde Tenant B
const results = await vectorStore.similaritySearch(
'CONFIDENTIAL_MARKER_TENANT_A_XK9',
{ filter: { tenant_id: { $eq: 'tenant_b' } }, k: 5 }
)
// Tenant B no debe ver ningún resultado de Tenant A
expect(results.length).toBe(0)
expect(results.every(r => r.metadata.tenant_id === 'tenant_b')).toBe(true)
})
})
Estos tests van en CI. Si algún cambio en la capa de acceso a datos o en el vector DB rompe el aislamiento, lo detectas antes de que lo detecte un cliente.
El coste real de construirlo mal desde el principio
Añadir multitenancy a posteriori no es un refactor limpio. Es una cirugía: cambiar el esquema de base de datos, migrar datos existentes, encontrar cada punto del código que hace queries sin tenant_id, actualizar la pila de llamadas completa.
En sistemas en producción con clientes activos, ese proceso puede llevar semanas y exige un cuidado extremo para no perder datos ni introducir nuevas fugas durante la migración.
El coste de diseñarlo bien desde el principio: dos o tres días de diseño de arquitectura antes de escribir la primera línea de código de negocio.
El coste de arreglarlo en producción con clientes activos: tres semanas mínimo, riesgo de incidente de datos, y una conversación incómoda con los clientes afectados.
En DAILYMP construimos agentes IA con arquitecturas multi-tenant correctas desde el diseño inicial. Si estás empezando un sistema que va a servir a más de un cliente, vale la pena revisar la arquitectura antes de que sea un problema. Y si ya tienes algo en producción y sospechas que el aislamiento no está funcionando bien, el primer paso es auditar cómo viaja el tenant_id por tu sistema.
Antes de escribir la primera línea
La separación de contexto entre tenants no es una feature que se añade cuando escala. Es una decisión de arquitectura que determina si tu sistema puede crecer sin comprometer la privacidad de los datos de tus clientes.
Si tienes dudas sobre el diseño de tu sistema de agentes multi-tenant, en DAILYMP hacemos ese tipo de revisión de arquitectura — con código concreto, no con diagramas genéricos.