Posts

IA híbrida en Swift: Foundation Models y Gemini bajo una misma sesión

Arturo Rivas Arias

Ejecutar un modelo de lenguaje dentro del dispositivo y llamar a otro alojado en la nube ya no tiene por qué traducirse en dos implementaciones independientes. Con las novedades de Foundation Models presentadas en la WWDC26, una app puede usar el modelo local de Apple para las tareas privadas y acotadas, recurrir a Gemini o ChatGPT cuando necesita más contexto o razonamiento y mantener una única API alrededor de LanguageModelSession.

La idea no consiste en decidir qué modelo es mejor en términos absolutos. Cada uno resuelve un conjunto distinto de restricciones. El modelo del sistema funciona sin conexión, no genera un coste por petición y mantiene los datos en el dispositivo. Un modelo remoto dispone de más capacidad de cómputo, una ventana de contexto mayor y funciones avanzadas, pero introduce latencia de red, el consumo es facturable y traspasa una frontera de privacidad que la persona debe conocer.

La pieza que hace posible esta arquitectura es el protocolo LanguageModel. SystemLanguageModel, Private Cloud Compute y los adaptadores de proveedores externos pueden alimentar la misma sesión. La capa que solicita una respuesta ya no necesita conocer el SDK concreto de cada proveedor: recibe un modelo compatible, crea la sesión y trabaja con respond(to:) o streamResponse(to:) de la misma forma.

import Foundation
import FoundationModels
import FirebaseAILogic

let localModel = SystemLanguageModel.default

let firebaseAI = FirebaseAI.firebaseAI(backend: .googleAI())
let cloudModel = firebaseAI.geminiLanguageModel(
    name: "gemini-3.7-flash"
)

// Para usar la nube, esta es la única línea que cambia a cloudModel.
let selectedModel: any LanguageModel = localModel
let session = LanguageModelSession(model: selectedModel)
let response = try await session.respond(
    to: "Propón tres nombres breves para una planta de interior."
)

print(response.content)

Cambiar una instancia parece un detalle pequeño, pero elimina una cantidad considerable de código duplicado. Las instrucciones, la generación guiada con @Generable, las herramientas y el historial de la conversación pertenecen a Foundation Models, no a una integración aislada. La selección del proveedor se convierte así en una decisión de configuración en vez de propagarse por la vista, el modelo observable y la capa de red.

El adaptador de Gemini de Firebase AI Logic para Foundation Models se encuentra actualmente en vista previa pública. Su configuración inicial requiere Xcode 27 y la rama wwdc26-preview del SDK de Firebase para Apple. Pero ya tenemos todas las piezas disponibles para empezar a trabajar.

La conexión se realiza añadiendo FirebaseAILogic y FirebaseAppCheck mediante Swift Package Manager, configurando el proyecto de Firebase e inicializando sus servicios al arrancar la app. App Check no es un complemento opcional: protege el acceso directo al servicio desde el cliente y evita que otra aplicación abuse de la cuota del proyecto. El proveedor de depuración solo debe utilizarse durante el desarrollo; una compilación distribuida necesita un proveedor de attestation de producción, como App Attest.

También hay una diferencia conceptual que la abstracción no puede ocultar. Cuando la sesión utiliza SystemLanguageModel, el contenido permanece en el dispositivo. Cuando activa Gemini, la petición abandona el dispositivo y queda sujeta a las condiciones, retención y facturación del servicio configurado. Compartir una interfaz de Swift no convierte ambos caminos en equivalentes desde el punto de vista de la privacidad. La aplicación debe mostrar esa transición, obtener permiso cuando corresponda y ofrecer una alternativa si la persona no acepta el procesamiento remoto.

📏 El primer impulso para escoger un modelo suele ser intentar la generación local y capturar cualquier error para repetirla en la nube. Ese patrón funciona en una demostración, pero es una mala política de enrutado. Si el texto no cabe, ya se habrá empleado tiempo y batería antes de descubrirlo. Además, un catch indiscriminado podría enviar a un proveedor externo un contenido que el modelo local rechazó por seguridad, no por falta de contexto.

Foundation Models expone tokenCount(for:) y contextSize para tomar la decisión antes de la generación. El cálculo no debe limitarse al texto escrito por la persona: las instrucciones, el esquema de una respuesta estructurada, la definición de herramientas y el historial de la sesión también consumen contexto. Asimismo, hay que reservar espacio para la salida. Codificar un límite fijo es frágil porque el modelo disponible puede cambiar con una actualización del sistema; consultar contextSize en ejecución mantiene la política ligada a la capacidad real.

Los perfiles dinámicos encapsulan esta política sin contaminar el punto de llamada. Un LanguageModelSession.DynamicProfile resuelve un único Profile activo, pero puede escoger entre varios mediante condiciones. Cada perfil agrupa instrucciones, herramientas y modificadores como el modelo, la temperatura o el nivel de razonamiento. Su body se vuelve a evaluar antes de cada petición, de modo que la configuración puede reaccionar al estado actual de la app.

El siguiente ejemplo usa una temática distinta: un planificador de comidas que procesa el inventario doméstico. Si la entrada cabe y el modelo del sistema está disponible, propone la cena de forma privada. Cuando el inventario y las restricciones son demasiado extensos, puede utilizar Gemini, pero únicamente si la persona ha autorizado el procesamiento en la nube.

import Foundation
import FoundationModels
import FirebaseAILogic

enum MealRoute: Sendable {
    case onDevice
    case cloud
}

enum MealPlannerError: LocalizedError {
    case cloudPermissionRequired

    var errorDescription: String? {
        switch self {
        case .cloudPermissionRequired:
            "La petición necesita más capacidad, pero el uso de la nube no está autorizado."
        }
    }
}

struct MealPlannerProfile: LanguageModelSession.DynamicProfile, Sendable {
    let route: MealRoute

    static let localInstructions = """
        Diseña una cena sencilla usando solo los ingredientes disponibles.
        Devuelve un nombre, los ingredientes elegidos y cuatro pasos breves.
        No inventes alérgenos ni sustituciones que no aparezcan en la entrada.
        """

    private static let cloudInstructions = """
        Actúa como planificador culinario. Cruza el inventario con las restricciones
        indicadas, minimiza el desperdicio y devuelve una receta clara y segura.
        Señala de forma explícita cualquier restricción que no puedas satisfacer.
        """

    var body: some LanguageModelSession.DynamicProfile {
        switch route {
        case .onDevice:
            Profile {
                Instructions(Self.localInstructions)
            }
            .model(SystemLanguageModel.default)
            .temperature(0.2)

        case .cloud:
            Profile {
                Instructions(Self.cloudInstructions)
            }
            .model(
                FirebaseAI.firebaseAI(backend: .googleAI())
                    .geminiLanguageModel(name: "gemini-3.7-flash")
            )
            .temperature(0.4)
        }
    }
}

La decisión de ruta se puede aislar en un actor. El ejemplo crea una sesión nueva para cada planificación, por lo que el presupuesto no necesita incluir conversaciones anteriores. En una experiencia de varios turnos habría que contar también el transcript conservado o recortarlo antes de continuar.

import FoundationModels

actor MealPlanner {
    private let responseReserve = 1_200

    func makePlan(
        inventory: String,
        allowsCloudProcessing: Bool
    ) async throws -> String {
        let route = try await selectRoute(
            for: inventory,
            allowsCloudProcessing: allowsCloudProcessing
        )

        let profile = MealPlannerProfile(route: route)
        let session = LanguageModelSession(profile: profile)
        let response = try await session.respond(to: inventory)

        return response.content
    }

    private func selectRoute(
        for inventory: String,
        allowsCloudProcessing: Bool
    ) async throws -> MealRoute {
        let model = SystemLanguageModel.default

        guard model.isAvailable else {
            return try cloudRoute(ifAllowed: allowsCloudProcessing)
        }

        let inputTokens = try await model.tokenCount(for: inventory)
        let instructionTokens = try await model.tokenCount(
            for: MealPlannerProfile.localInstructions
        )

        let requiredTokens = inputTokens
            + instructionTokens
            + responseReserve

        if requiredTokens <= model.contextSize {
            return .onDevice
        }

        return try cloudRoute(ifAllowed: allowsCloudProcessing)
    }

    private func cloudRoute(ifAllowed isAllowed: Bool) throws -> MealRoute {
        guard isAllowed else {
            throw MealPlannerError.cloudPermissionRequired
        }

        return .cloud
    }
}

Esta separación evita tres errores frecuentes. La vista no elige proveedores, el permiso para usar la nube forma parte explícita de la política y una entrada demasiado grande no se ejecuta dos veces. También permite probar selectRoute con distintos presupuestos sin realizar ninguna petición real, algo especialmente valioso cuando la ruta remota tiene coste.

El tamaño no debería ser la única señal. Una política de producción puede considerar la conectividad, el modo de bajo consumo, la sensibilidad de los datos, el tipo de tarea, la latencia aceptable y el presupuesto mensual. Una extracción breve puede permanecer en el dispositivo aunque haya red, mientras que una tarea de razonamiento complejo podría ir directamente a un modelo remoto aunque su entrada sea pequeña. El perfil dinámico ofrece el mecanismo; la aplicación sigue siendo responsable de definir la política correcta.

El tratamiento de errores también debe conservar la intención original. contextSizeExceeded puede justificar una segunda ruta si la persona autorizó la nube. Un fallo de conectividad debería producir un estado recuperable. Una infracción de las medidas de seguridad o un rechazo del modelo no debe convertirse automáticamente en una petición a otro proveedor. Clasificar los errores es más trabajo que envolver toda la generación en un único catch, pero evita filtraciones inesperadas y comportamientos imposibles de explicar.

Para respuestas largas, streamResponse(to:) mejora la percepción de velocidad tanto con modelos locales como remotos. La API entrega instantáneas acumulativas, no fragmentos independientes, por lo que la interfaz debe reemplazar el texto visible con snapshot.content en cada iteración. La abstracción vuelve a ser útil: el mecanismo de presentación no cambia cuando cambia el modelo que genera la respuesta.

func streamPlan(
    from session: LanguageModelSession,
    inventory: String,
    onUpdate: @MainActor (String) -> Void
) async throws {
    for try await snapshot in session.streamResponse(to: inventory) {
        await onUpdate(snapshot.content)
    }
}

La observabilidad completa el diseño. Los modelos externos suelen facturar por tokens y las nuevas sesiones exponen información de uso, incluidos tokens de entrada, salida, caché y razonamiento cuando el proveedor los admite. Registrar métricas agregadas —sin guardar el contenido privado— permite comprobar si el enrutado realmente reduce costes y latencia. También conviene medir calidad: una respuesta local más barata deja de ser una ventaja si obliga a la persona a repetir la operación.

Los perfiles facilitan las pruebas comparativas porque mantienen estable el punto de entrada y permiten variar la configuración. Se pueden preparar conjuntos de inventarios representativos, evaluar precisión, tiempo de primera respuesta, consumo total y frecuencia de derivación a la nube. Las decisiones deberían apoyarse en esos datos y no en la suposición de que «local» siempre significa más rápido o que «remoto» siempre produce una respuesta mejor.

El resultado es una arquitectura con límites claros. Foundation Models aporta la sesión, el historial, las herramientas y la generación estructurada; cada LanguageModel decide dónde se ejecuta la inferencia; el perfil dinámico concentra la selección; y la aplicación conserva el control sobre permisos, presupuesto y experiencia de usuario. Esta división permite incorporar nuevos modelos sin reescribir la funcionalidad completa y, al mismo tiempo, evita que una abstracción técnica oculte decisiones que afectan a la privacidad.

La IA híbrida en Swift no es un mecanismo de respaldo añadido al final del desarrollo. Es una política de producto expresada en código: usar el dispositivo cuando ofrece capacidad suficiente, ampliar a la nube cuando aporta un beneficio real y hacer visible el cambio de frontera. Con LanguageModel, LanguageModelSession y los perfiles dinámicos, esa política puede vivir en un único lugar, ser comprobable y evolucionar sin arrastrar al resto de la aplicación.