Memoria de tres capas para un compañero de IA con un modelo de 3B: cómo dar memoria larga a un LLM on-device#
Hay un hecho sencillo que rompe casi todas las intuiciones sobre cómo construir un compañero de IA: un modelo de lenguaje on-device en un iPhone no tiene una ventana de contexto de 200.000 tokens como los mejores modelos en la nube, sino alrededor de 4096. Y eso no es solo la entrada: las instrucciones del sistema y la propia respuesta del modelo comparten el mismo presupuesto. Si diseñas una función como si hubiera un modelo de la clase GPT-4 con memoria casi ilimitada en el bolsillo de alguien, se rompe al tercer día de conversación: el modelo "olvida" el inicio del chat o empieza a confundir lo que realmente está pasando.
Actualmente estoy construyendo Lanternly, un compañero de diario con una IA que funciona por completo on-device a través de Apple Foundation Models (escribí sobre el framework en sí por separado: Apple Foundation Models: LLM on-device en una app iOS). Mi primera arquitectura de memoria no sobrevivió al contacto con la realidad: o el contexto se desbordaba y el modelo empezaba a divagar, o tenía que recortar el historial de forma tan agresiva que se perdían detalles importantes. La solución no fue un truco ingenioso de truncado, sino dividir la memoria en tres capas independientes, cada una con su propio rol, su lugar de almacenamiento y su dueño. Aquí está cómo está construida, y por qué.
Por qué 4096 tokens es otra arquitectura, no una versión reducida#
Los desarrolladores que solo han trabajado con LLM en la nube suelen resolver la memoria de una sola manera: "enviemos más historial en el prompt". Con una ventana de 200.000+ tokens eso casi siempre funciona: se pueden meter docenas de mensajes, documentos enteros, fragmentos de una base de conocimiento, y el modelo se las arregla. Eso crea la falsa sensación de que gestionar el contexto es un detalle de implementación que se puede posponer, no una decisión arquitectónica.
Eso no funciona on-device. Un presupuesto de 4096 tokens se reparte entre las instrucciones (el system prompt con la persona, el tono, las reglas), la propia respuesta (hay que dejar espacio, o la generación se corta a mitad de frase) y lo que queda — normalmente entre 2500 y 3000 tokens para todo el contexto de la conversación. Eso son unas pocas decenas de turnos cortos, no más. Así que gestionar el contexto no es una optimización, es un requisito sin el cual la función simplemente no funciona. Lo que sigue no es un único truco de "recorte inteligente del historial" — son tres mecanismos separados, cada uno resolviendo una parte distinta del problema.
Capa 1 — el contexto del modelo: ventana deslizante con resumen al desbordarse#
La primera capa es lo que llega físicamente al prompt de una llamada concreta al modelo: las instrucciones del sistema más los últimos N turnos de la conversación. La solución ingenua es simplemente descartar los mensajes más antiguos cuando el espacio empieza a faltar. Funciona, pero se paga con la propia conversación: si la persona mencionó algo importante cinco mensajes atrás y la ventana ya se desplazó, el modelo nunca lo verá.
La solución que funciona es una ventana deslizante que, al desbordarse, no descarta los turnos antiguos sino que los pliega en un resumen corto mediante una llamada extra al modelo. La mitad superior de la ventana se colapsa en 2–3 frases de resumen; la mitad inferior se mantiene textual. Esa es la diferencia entre "olvidar" y "comprimir": el modelo pierde la redacción exacta pero conserva la esencia.
import FoundationModels
/// Layer 1 — the model's own context: a sliding window that collapses
/// into a rolling summary instead of silently dropping older turns.
struct ConversationWindow {
private let maxTokens: Int
private(set) var turns: [ChatTurn] = []
private(set) var rollingSummary: String?
init(maxTokens: Int = 3200) {
self.maxTokens = maxTokens
}
/// Rough heuristic: ~4 characters per token (good enough for budgeting,
/// not for billing — on-device models don't charge per token anyway).
private func estimatedTokens(_ text: String) -> Int {
text.count / 4
}
private var currentTokens: Int {
turns.reduce(rollingSummary.map(estimatedTokens) ?? 0) {
$0 + estimatedTokens($1.text)
}
}
mutating func append(_ turn: ChatTurn) async {
turns.append(turn)
guard currentTokens > maxTokens, turns.count > 4 else { return }
await collapseOldest()
}
/// Move the oldest half of the window into a rolling summary and keep
/// only the freshest turns verbatim. This is the difference between
/// "forgetting" and "compressing" — the model still knows the gist.
private mutating func collapseOldest() async {
let cut = turns.count / 2
let evicted = Array(turns.prefix(cut))
turns.removeFirst(cut)
rollingSummary = await summarize(evicted, previous: rollingSummary)
}
private func summarize(_ evicted: [ChatTurn], previous: String?) async -> String {
guard case .available = SystemLanguageModel.default.availability else {
return previous ?? ""
}
let session = LanguageModelSession(instructions: """
Summarize the earlier part of this conversation in 2-3 neutral \
sentences. Keep names, decisions and open questions; drop small talk.
""")
let transcript = evicted.map { "\($0.role): \($0.text)" }.joined(separator: "\n")
let prompt = previous.map { "Previous summary: \($0)\n\nNew turns:\n\(transcript)" } ?? transcript
return (try? await session.respond(to: prompt).content) ?? (previous ?? "")
}
}
struct ChatTurn {
enum Role: String { case user, assistant }
let role: Role
let text: String
}Importante: esta capa es efímera. Existe solo dentro de una LanguageModelSession concreta y se reconstruye desde cero en cada llamada al modelo, a partir de los datos que poseen las capas 2 y 3. No persiste nada por sí misma.
Capa 2 — historial de chat en disco: SwiftData + iCloud#
La segunda capa responde a la pregunta que la capa 1 ignora deliberadamente: ¿qué pasó realmente en la conversación, completo, sin comprimir? Es un registro completo e inalterado de cada turno — almacenado mediante SwiftData localmente en el dispositivo y sincronizado entre los dispositivos de la persona a través de iCloud.
La diferencia con la capa 1 es fundamental: la capa 1 es una proyección de trabajo, recortada, para la llamada actual al modelo; la capa 2 es la fuente de verdad. De ahí se construyen los resúmenes, ahí puede volver la persona para releer una conversación antigua, y eso es lo que la persona puede borrar — entero o en parte — sin que eso rompa de ninguna forma la sesión actual con el modelo.
import SwiftData
/// Layer 2 — chat history on disk: the full, uncompressed log, kept
/// independent from whatever the model's context window currently holds.
@Model
final class ChatMessage {
var id: UUID = UUID()
var role: ChatTurn.Role = .user
var text: String = ""
var createdAt: Date = .now
init(role: ChatTurn.Role, text: String) {
self.role = role
self.text = text
}
}La consecuencia práctica: si la app pierde el hilo de una conversación por desbordamiento del contexto, la persona no pierde nada — todo el historial está intacto en disco. Simplemente no cabe ahora mismo en el presupuesto de una sola llamada al modelo, y eso es un problema distinto que no debería resolverse con el mismo código.
Capa 3 — "Memoria sobre ti": hechos que se ven y se pueden borrar#
La tercera capa es un resumen corto y editable de hechos duraderos sobre la persona: preferencias, personas importantes, hábitos — cosas que vale la pena recordar entre conversaciones completamente distintas, incluso cuando la capa 1 ya lo olvidó todo hace tiempo y releer toda la capa 2 por un solo detalle sería innecesariamente costoso.
Los hechos llegan por dos vías. La persona puede añadirlos manualmente. Y el modelo puede, de forma best-effort y sin bloquear la respuesta principal, intentar extraer un hecho duradero de un mensaje suficientemente largo. Si no hay ningún hecho, el modelo responde con un marcador especial y no se guarda nada; si un hecho se parece a uno ya existente, se descarta como duplicado.
import SwiftData
/// Layer 3 — "Memory about you": a short, editable list of durable facts.
/// Lives on disk (and syncs via iCloud), independent of any single chat
/// session. The user can see, edit and delete every entry.
@Model
final class MemoryFact {
var id: UUID = UUID()
var text: String = ""
var source: MemorySource = .manual
var createdAt: Date = .now
init(text: String, source: MemorySource = .manual) {
self.text = text
self.source = source
}
}
enum MemorySource: String, Codable {
case automatic // extracted by the model from a conversation
case manual // added directly by the user
}
/// Best-effort auto-extraction: after a long-enough message, ask the model
/// for at most one durable fact. Never blocks the reply if it fails.
enum MemoryExtractor {
static func extract(from text: String, existing: [MemoryFact]) async -> MemoryFact? {
guard text.count > 25,
case .available = SystemLanguageModel.default.availability else { return nil }
let session = LanguageModelSession(instructions: """
Extract ONE durable fact about the person from their message \
(a preference, an important person, a habit worth remembering). \
One short phrase, no quotes. If there is no durable fact, reply NONE.
""")
guard let response = try? await session.respond(to: text) else { return nil }
let fact = response.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !fact.isEmpty, fact.uppercased() != "NONE", fact.count < 120 else { return nil }
let isDuplicate = existing.contains {
$0.text.localizedCaseInsensitiveContains(fact)
}
return isDuplicate ? nil : MemoryFact(text: fact, source: .automatic)
}
}La decisión arquitectónica clave aquí no es técnica, es de producto: cada hecho está etiquetado con su origen (automatic / manual), y la persona ve la lista completa en los ajustes, puede editar la redacción de cualquier entrada o borrarla sin dejar rastro. Nada queda oculto "entre líneas" del modelo, y nada regresa después de ser borrado.
Ensamblar el contexto: encajar tres capas en un presupuesto de tokens#
Antes de cada llamada al modelo, las tres capas se empaquetan en una única cadena de instrucciones que debe encajar dentro del límite real de Foundation Models — 4096 tokens para todo el intercambio, respuesta incluida. El orden importa: la capa 3 va primero, porque los hechos sobre la persona son baratos (un par de líneas) y aportan al modelo el mayor valor por token gastado. Luego viene la parte comprimida de la capa 1 (el resumen acumulado), si existe. Y solo lo que quede del presupuesto se destina a la cola textual de los turnos recientes, empezando por los más nuevos.
/// Packs all three layers into one instructions string that fits inside
/// the model's real context limit (Foundation Models: ~4096 tokens total,
/// input + output). Order matters: identity facts are cheap and high-value,
/// so they're never the first thing dropped.
struct PromptBudget {
let totalTokens = 4096
let reservedForResponse = 512
let reservedForInstructions = 300
var availableForContext: Int { totalTokens - reservedForResponse - reservedForInstructions }
func assemble(memory: [MemoryFact], summary: String?, recentTurns: [ChatTurn]) -> String {
var budget = availableForContext
var blocks: [String] = []
// Layer 3 — identity facts first: small, stable, high signal.
if !memory.isEmpty {
let text = memory.map { "- \($0.text)" }.joined(separator: "\n")
blocks.append("About the person:\n\(text)")
budget -= text.count / 4
}
// Layer 1, compressed part — the rolling summary of evicted turns.
if let summary, !summary.isEmpty, budget > 200 {
blocks.append("Earlier in the conversation: \(summary)")
budget -= summary.count / 4
}
// Layer 1, verbatim tail — fill what's left, newest turns first.
var tail: [String] = []
for turn in recentTurns.reversed() {
let cost = turn.text.count / 4
guard cost < budget else { break }
tail.insert("\(turn.role.rawValue): \(turn.text)", at: 0)
budget -= cost
}
blocks.append(contentsOf: tail)
return blocks.joined(separator: "\n\n")
}
}Este es el código que convierte tres capas separadas en una experiencia fluida para la persona, mientras las capas en sí mismas siguen siendo independientes y cada una resuelve su propia parte del problema.
| Capa | Qué almacena | Dónde vive | Quién la gestiona |
|---|---|---|---|
| 1. Contexto del modelo | Ventana deslizante de turnos recientes + resumen acumulado al desbordarse | Solo dentro de la LanguageModelSession actual, efímera | El modelo y el código de la app, automáticamente |
| 2. Historial de chat | Registro completo y sin comprimir de cada turno | SwiftData local + sincronización con iCloud | La app lo guarda automáticamente; la persona puede borrar una conversación entera |
| 3. "Memoria sobre ti" | Una lista corta de hechos duraderos sobre la persona | SwiftData local + iCloud | La persona — ve, edita y borra cada entrada |
On-device vs. la nube: por qué no se puede copiar sin más MemGPT o mem0#
La tentación de tomar prestado un patrón ya hecho del mundo de los agentes en la nube es fuerte. MemGPT, y el framework Letta construido sobre esa misma idea, tratan la ventana de contexto como memoria virtual al estilo de un sistema operativo: la memoria central es la "RAM" del modelo, mientras que los almacenes de archivo y de recall son el "disco", y el propio modelo decide qué traer llamando a herramientas dedicadas en mitad de la conversación. mem0 toma otro camino — después de cada turno, una llamada LLM separada extrae hechos atómicos y clasifica una operación sobre ellos (añadir, actualizar, borrar o no hacer nada), comparando con entradas similares en una base vectorial.
Ambos enfoques son sólidos e inteligentes — y ambos asumen un presupuesto de 200.000+ tokens y que una llamada extra al modelo casi no cuesta ni tiempo ni dinero. En un modelo pequeño on-device con un techo de 4096 tokens y sin base de datos vectorial propia, eso literalmente no escala: cada llamada extra de "que el modelo decida qué recordar" consume el mismo presupuesto que el modelo necesita para la respuesta real. Por eso la capa 3 en esta arquitectura no le entrega al modelo el control de su propia memoria — es un mecanismo determinista y barato, con un prompt de extracción fijo, un límite de longitud estricto y una deduplicación simple por subcadena; la app, no el modelo, decide cuándo ejecutarlo y qué cabe en el presupuesto. Menos flexible que MemGPT o mem0, pero predecible y lo bastante barato como para ejecutarse después de cada mensaje, directamente en el teléfono.
La privacidad como arquitectura, no como un interruptor en los ajustes#
La memoria de tres capas tiene un efecto secundario que, en la práctica, importa más que cualquier optimización de tokens: es estructuralmente lo opuesto a una caja negra. El historial y los hechos viven en SwiftData bajo la propia cuenta de iCloud de la persona — no en la nube de otro, no en un almacén vectorial en un servidor al que la persona no tiene acceso. La capa 3 no oculta lo que la app "sabe" sobre la persona: la lista de hechos es visible, cada entrada se puede corregir o borrar, y una vez borrada no reaparece desde una caché oculta o un índice vectorial — porque no existe ningún índice oculto.
Esto no es solo un detalle agradable para un compañero de diario en particular. Es una conclusión arquitectónica aplicable a cualquier función de memoria larga construida sobre un modelo pequeño on-device: si no tienes presupuesto para una infraestructura en la nube al nivel de MemGPT, dividir la memoria en tres capas no te da un sustituto inferior de la memoria "real" a largo plazo — te da una memoria que le dice honestamente a la persona qué guarda y por qué.
Para terminar, tres preguntas que vale la pena responder antes de diseñar la memoria de tu propia función de LLM on-device:
- ¿Qué debe entrar en esta llamada concreta al modelo, ahora mismo — y puedes calcular realmente el presupuesto en lugar de adivinarlo?
- ¿Qué hay que conservar completo, aunque el modelo nunca lo vea de una sola vez?
- ¿Qué puñado de hechos merece sobrevivir a cualquier conversación individual — y estás dispuesto a mostrárselos a la persona tal cual están guardados?
Responde con claridad a las tres, y la memoria de un modelo pequeño on-device deja de ser su punto débil para convertirse en una decisión arquitectónica deliberada — con un nivel de transparencia que las cajas negras en la nube normalmente no pueden igualar.



