ZaatarLABS
Escríbenos →
← Todas las entradas
The Smart Budget · Ingeniería

Lo que costó crear una app de finanzas sobre el LLM en el dispositivo de Apple

El modelo en el dispositivo es real, es gratis y te mentirá con total seguridad. Esta es la arquitectura que sobrevivió al contacto con él.

11 min de lectura

Actualización, 29 de septiembre de 2026: este artículo describe la versión para iOS 26. Desde Smart Budget 2.0 en iOS 27, la IA de la app prefiere Private Cloud Compute de Apple y recurre al modelo en el dispositivo como respaldo: más sobre ese cambio. Tus transacciones siguen sin llegar nunca a nosotros ni a terceros.

Apple lanzó un modelo de lenguaje en el dispositivo con iOS 26. Sin clave de API, sin llamadas de red, sin factura por token, sin un límite de uso que puedas ver. Para una app como la mía (una app de presupuesto cuya premisa entera es que tus datos nunca salen del teléfono) eso no es un extra. Es la única forma en que las funciones «inteligentes» podían existir.

Construí The Smart Budget sobre él. Este es el informe honesto: en qué es realmente bueno el modelo, lo único que hizo que casi acaba con la función y la arquitectura a la que llegué después de aprender por las malas. Todo el código que aparece aquí es de la app publicada.

La promesa frente a la realidad de 3B

El discurso de la WWDC embriaga: crea funciones con LLM que se ejecutan en local, publícalas y listo. Y buena parte es cierta. Pero el modelo en el dispositivo de iOS 26 es un modelo de ~3000 millones de parámetros. No es GPT-4 en tu bolsillo. Tratarlo como si lo fuera es la manera de publicar algo que te avergüence.

Esta es la división que encontré tras unas semanas:

En qué es realmente bueno el modelo de 3B: la extracción estructurada de una sola pasada. Dale una tarea corta y muy acotada («lee esta frase y elige una de estas cinco intenciones») con un esquema que restrinja la salida, y es excelente. Rápido, fiable, y no se sale de las líneas que le marcas.

En qué es malo: en todo lo abierto. Conversaciones de varios turnos. Prosa libre que además tiene que ser precisa en los hechos. En cuanto le dejas escribir frases sobre números, inventa detalles alrededor de esos números con la cara más seria del mundo.

Toda la arquitectura se desprende de respetar esa división.

El patrón que funciona: intérprete → Swift determinista → narrador

La regla con la que me quedé: deja que el modelo entienda el lenguaje, y nunca le dejes hacer cálculos ni afirmar hechos. En concreto, cada consulta inteligente de la app pasa por tres etapas, y solo la primera toca el modelo.

// Stage 1: LLM extraction (constrained). Falls back to a tiny keyword
// pass automatically if FM is unavailable / errors.
let question = await QuerySpecExtractor.extract(
    from: trimmed,
    previous: conversationContext?.lastQuestion,
    knownCategoryNames: knownCategoryNames,
    knownMerchantsByFrequency: knownMerchantNames
)
// Stage 2: deterministic query.
let answer = await FinanceQueries.answer(question, controller: controller)
// Stage 3: deterministic prose render.
let reply = FinanceNarrator.compose(answer: answer, userQuestion: trimmed)

La etapa 1 convierte «¿cuánto gasté en restaurantes el mes pasado?» en un FinanceQuestion tipado: una intención (un enum), un periodo y, opcionalmente, un comercio o una categoría. La etapa 2 es Core Data a secas. La etapa 3 da formato al resultado. Fíjate en lo que tienen en común las etapas 2 y 3: ningún modelo. La cabecera de la capa de consultas lo dice en voz alta:

/// `context.perform`. NO LLM involvement; no `Tool` protocol; no orchestration.

El esquema de extracción, y el truco que lo hace fiable

La etapa 1 usa DynamicGenerationSchema, que permite construir el esquema de salida en tiempo de ejecución a partir de los datos del propio usuario. Esa parte en tiempo de ejecución importa más de lo que parece. El esquema se arma para cada pregunta y (este es el truco) una propiedad solo existe en el esquema cuando aparece en la consulta una señal que la justifique:

var properties: [DynamicGenerationSchema.Property] = [
    .init(name: "intent", schema: intentSchema),
    .init(name: "period", schema: periodSchema),
    .init(name: "otherPeriod", schema: otherPeriodSchema, isOptional: true),
    .init(name: "limit", schema: limitSchema, isOptional: true),
]
if !merchantNames.isEmpty {
    let merchantSchema = DynamicGenerationSchema(
        name: "merchant",
        description: "Merchant name the user named. Copy EXACTLY from this list.",
        anyOf: merchantNames
    )
    properties.append(.init(name: "merchant", schema: merchantSchema, isOptional: true))
}

Si el usuario no nombró ningún comercio, la propiedad merchant no está en el esquema en absoluto, así que el modelo estructuralmente no puede inventarse uno. No le estás pidiendo amablemente que no invente un comercio; has hecho imposible inventarlo. La decodificación restringida convierte «por favor, pórtate bien» en «no puedes portarte mal». Después lo ejecutas:

let session = LanguageModelSession(model: SystemLanguageModel.default)
let response = try await session.respond(
    to: prompt,
    schema: schema,
    includeSchemaInPrompt: true,
    options: GenerationOptions(temperature: 0.1)  // we want deterministic-ish picks
)

Temperatura 0.1, porque esto es una clasificación, no un poema. E incluso con anyOf restringiendo las opciones, hay una comprobación extra después que descarta cualquier comercio o categoría que haya devuelto el modelo salvo que aparezca literalmente un término coincidente en el texto del usuario. Confía, luego verifica y luego vuelve a verificar: es una app de finanzas.

El patrón que no funciona: dejar que narre

Esta es la parte que quiero que lea todo desarrollador antes de publicar.

La etapa 3 original era un LLM. Le pedía al modelo en el dispositivo que «reescribiera la respuesta determinista en una o dos frases cálidas», con instrucciones explícitas de no cambiar ningún número. Obedeció la instrucción sobre los números. Y luego se inventó todo lo que los rodeaba. Del comentario real que ahora ocupa el lugar donde estaba esa función:

/// The previous version asked the on-device Foundation Model to "rewrite the
/// deterministic answer in 1-2 warm sentences," with explicit rules against
/// changing numbers. The model honoured the numbers but invented EVERYTHING
/// ELSE — McDonald's transactions the user never made, merchant names like
/// "Bubble-bro.com" pulled out of thin air, fake per-item amounts. Pure
/// hallucination. On a small on-device model the prompt rules don't stick
/// reliably enough for a finance app where any invented detail destroys trust.

«Bubble-bro.com». Un comercio que nunca ha existido, en un resumen de los gastos reales de una persona, presentado como un hecho. En una app de presupuesto. No hay prompt ingenioso que haga eso aceptable. Que el número sea correcto no importa si la frase que lo rodea es ficción.

Así que la etapa 3 ahora es una plantilla de texto. No porque las plantillas sean elegantes, sino porque una plantilla no puede mentir:

static func compose(answer: FinanceAnswer, userQuestion: String) -> String {
    if answer.detail.isEmpty {
        return answer.summary
    }
    return answer.summary + "\n\n" + answer.detail.joined(separator: "\n")
}

(La app sí sigue dejando que el modelo narre en algunos sitios, las tarjetas de análisis, pero solo bajo las estrictas salvaguardas de resultado que explico más abajo, y nunca donde pudiera inventarse una transacción).

Antes de esta, también probé las dos arquitecturas «obvias», y ambas fallaron con el modelo de 3B. El razonamiento se conserva en la cabecera del tipo:

/// **Why structured + LLM-extracted:** prior architectures using either
/// (a) `LanguageModelSession`-with-tools or (b) hand-written regex parser
/// both failed. (a) the on-device 3B model can't reliably orchestrate tool
/// calls across multi-turn chat; (b) regex didn't generalise to the long
/// tail of natural phrasings ("on what?", "what are these?", "and dining
/// only?"). The current architecture uses constrained DynamicGenerationSchema
/// for natural-language UNDERSTANDING (front of pipeline) + deterministic
/// Swift for everything else.

La llamada a herramientas es la más tentadora, porque es como lo harías con un modelo de frontera. En el dispositivo, el modelo no conseguía orquestar herramientas de forma fiable entre turnos. Extraer a un struct es menos vistoso y muchísimo más robusto.

Tampoco confíes en la transcripción de la sesión como memoria

Las conversaciones de varios turnos te tientan a apoyarte en la transcripción del modelo como memoria. No lo hagas. La ventana de contexto es pequeña y se llena rápido. Yo guardo el estado de la conversación en Swift puro y vuelvo a incluir la pregunta anterior en cada nuevo prompt:

/// **Why not use the model's session transcript for multi-turn memory:**
/// the on-device 4096-token context fills up fast and Apple's developer
/// guidance is that the transcript is an optimisation, not a memory system.
/// We maintain `ConversationContext` ourselves in Swift and inline the
/// previous spec into each turn's prompt — same effect, deterministic memory.

4096 tokens. Ese es todo el presupuesto. Por eso también la lista de comercios que se le pasa al extractor viene recortada de antemano a unas ~30 entradas: la lista real puede tener 1000+ entradas, y meterla entera llenaría la ventana antes de que el modelo viera la pregunta.

Las salvaguardas que de verdad necesitas

Cada llamada al modelo pasa por un único cliente que envuelve la respuesta en un resultado tipado, para que cada llamador decida: reintentar, recurrir a la ruta determinista o mostrar el error. Las tres que se ganaron su lugar:

Espera progresiva ante límites de uso. Apple no publica los límites, así que los descubres en tiempo de ejecución. Espera exponencial con variación aleatoria y, después, rendirse para que el llamador pueda recurrir a otra ruta en lugar de quedarse colgado:

case .rateLimited:
    guard attemptsLeft > 1 else {
        logger.warning("FM rate-limited ... giving up so caller can fall back")
        return .rateLimited
    }
    let baseSeconds = pow(2.0, Double(attemptIndex + 1))   // 2s, 4s, 8s
    let jitter = Double.random(in: 0.75...1.25)            // ±25%
    try? await Task.sleep(nanoseconds: UInt64(baseSeconds * jitter * 1_000_000_000))
    return await generateOnce(..., attemptsLeft: attemptsLeft - 1, attemptIndex: attemptIndex + 1)

El desbordamiento de contexto no se reintenta. Si te pasas de la ventana de 4096 tokens, reintentar el mismo prompt no sirve de nada: el llamador tiene que dividir el trabajo. Por eso se expone como un resultado propio y distinto, no mezclado con los fallos transitorios.

Serializa todo. Todas las llamadas al modelo pasan por un único actor, para que dos funciones no puedan lanzar sesiones en paralelo y gastar el doble de rápido el presupuesto invisible de uso. Y hay un interruptor maestro (una opción de Ajustes combinada con AND con la disponibilidad del dispositivo) que, cuando está apagado, hace que cada llamada devuelva .unavailable, de modo que toda la app toma su ruta determinista: análisis de SMS con expresiones regulares, un categorizador determinista y un chat de respaldo por palabras clave. La capa inteligente es una mejora, nunca una dependencia. La app se puede usar por completo con el modelo totalmente desactivado.

La función que construí, terminé y luego escondí

En el código hay un chat conversacional completo, «Pregunta lo que quieras»: puedes hacer preguntas sobre tu dinero en lenguaje natural y responde usando la canalización de arriba. Está implementado. Compila. Y cada punto de entrada está comentado, con la etiqueta ASK-ANYTHING-DEFERRED-IOS27:

// ASK-ANYTHING-DEFERRED-IOS27: chat sheet presentation
// disabled. Implementation kept intact ...
// The 3B model in iOS 26 was unable to reliably extract
// intent + filters even with constrained DynamicGenerationSchema
// — revisit when iOS 27 / a larger on-device model lands.

Fue la decisión más difícil del proyecto. La función funciona, casi siempre. Pero «casi siempre» es un suspenso cuando la función es «pregúntame lo que quieras sobre tu dinero». Incluso con todas las restricciones anteriores, la extracción de intenciones del modelo de 3B no era lo bastante fiable como para apostar en ella la confianza en la app. Así que ahí está, terminada, esperando el modelo que traiga iOS 27. Publicar la canalización pero esconder el chat marcó la diferencia entre «una demo ingeniosa» y «una app que dejaría usar a mi propia familia».

Lo que me diría a mí mismo el primer día

  1. Diseña para el patrón restringido desde el principio. El modelo como intérprete, Swift como fuente de verdad. No empieces con un chatbot para intentar añadirle fiabilidad después; acabarás reescribiéndolo.
  2. Nunca dejes que el modelo afirme un hecho. Extracción a la entrada; lógica determinista y plantillas a la salida. Si el modelo puede emitir el nombre de un comercio, tarde o temprano emitirá uno falso.
  3. Haz que la alucinación sea estructuralmente imposible, no solo desaconsejada. Una propiedad que no está en el esquema no se puede inventar. Las reglas del prompt son sugerencias; los esquemas son muros.
  4. Trata la capa inteligente como una mejora. Publica primero la app determinista y luego deja que el modelo la mejore allí donde se pueda confiar en él.

El modelo en el dispositivo es una herramienta realmente buena en cuanto dejas de pedirle que sea algo que no es. No es un cerebro al que le entregas el volante. Es un intérprete de intenciones muy bueno y muy rápido que, además, se ejecuta gratis en el teléfono, y eso, resulta, es casi todo lo que necesita una app de finanzas privada.

The Smart Budget ya está disponible para iPhone (iOS 26, iPhone 16 o posterior). En iOS 26 todo lo anterior se ejecuta en el dispositivo; en iOS 27 la app prefiere Private Cloud Compute de Apple, y tus transacciones siguen sin llegar nunca a nosotros ni a terceros. Si quieres la parte de CloudKit de la historia (dos cuentas de iCloud que comparten un presupuesto sin servidor), está en el siguiente artículo.

— Omar

Escrito por Omar Al Homaidi · The Smart Budget · @zaatarlabs