Los modelos de IA en el dispositivo no son estaticos. Tus datos de entrenamiento mejoran, tu fine-tuning mejora y se lanzan nuevos modelos base. Actualizar el modelo no deberia requerir una actualizacion completa de la app a traves del App Store.
Las actualizaciones over-the-air (OTA) de modelos te permiten enviar nuevos archivos GGUF a los usuarios independientemente del binario de la app. La app verifica actualizaciones, descarga el nuevo modelo en segundo plano y lo intercambia sin problemas.
Arquitectura
El manifiesto del modelo
Aloja un manifiesto JSON en tu CDN junto al archivo del modelo:
{
"current_version": "2.1.0",
"models": {
"1b": {
"url": "https://cdn.example.com/models/v2.1.0/model-1b-q4.gguf",
"size_bytes": 612000000,
"sha256": "a1b2c3d4e5f6...",
"min_app_version": "3.0.0",
"release_notes": "Precision de clasificacion mejorada"
},
"3b": {
"url": "https://cdn.example.com/models/v2.1.0/model-3b-q4.gguf",
"size_bytes": 1740000000,
"sha256": "f6e5d4c3b2a1...",
"min_app_version": "3.0.0",
"release_notes": "Mejor calidad de conversacion"
}
},
"rollback_version": "2.0.0",
"rollback_url_1b": "https://cdn.example.com/models/v2.0.0/model-1b-q4.gguf",
"rollback_url_3b": "https://cdn.example.com/models/v2.0.0/model-3b-q4.gguf"
}El manifiesto le dice a la app: cual es la ultima version, donde descargarla, como verificarla, y a que recurrir si algo sale mal.
Flujo de verificacion de actualizacion
[Lanzamiento de app] -> [Obtener manifiesto del CDN]
-> [Comparar version local con version del manifiesto]
-> [Si hay version mas nueva disponible]:
-> [Verificar WiFi + almacenamiento suficiente]
-> [Descargar nuevo modelo en segundo plano]
-> [Verificar SHA256]
-> [Intercambiar modelo en siguiente inicio de sesion]
-> [Si la version actual coincide]: [Sin accion]
Implementacion
// iOS: Verificar actualizaciones de modelo
class ModelUpdater {
private let manifestURL = URL(string: "https://cdn.example.com/manifest.json")!
func checkForUpdate() async -> ModelUpdate? {
guard let data = try? await URLSession.shared.data(from: manifestURL).0,
let manifest = try? JSONDecoder().decode(ModelManifest.self, from: data)
else { return nil }
let currentVersion = UserDefaults.standard.string(forKey: "model_version") ?? "0.0.0"
if manifest.currentVersion > currentVersion {
return ModelUpdate(
version: manifest.currentVersion,
url: manifest.models[selectedTier]!.url,
size: manifest.models[selectedTier]!.sizeBytes,
hash: manifest.models[selectedTier]!.sha256
)
}
return nil
}
}// Android: Verificar actualizaciones al lanzar la app
class ModelUpdater(private val context: Context) {
suspend fun checkForUpdate(): ModelUpdate? = withContext(Dispatchers.IO) {
val manifest = fetchManifest() ?: return@withContext null
val currentVersion = prefs.getString("model_version", "0.0.0")
if (manifest.currentVersion > currentVersion) {
val model = manifest.models[selectedTier]
ModelUpdate(
version = manifest.currentVersion,
url = model.url,
sizeBytes = model.sizeBytes,
sha256 = model.sha256
)
} else null
}
}Descarga en segundo plano
Las descargas de modelos deben ocurrir en segundo plano sin bloquear al usuario:
iOS: URLSession en segundo plano
func downloadUpdate(_ update: ModelUpdate) {
let config = URLSessionConfiguration.background(
withIdentifier: "com.app.model-download"
)
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
let task = session.downloadTask(with: update.url)
task.resume()
}
// El delegate maneja la completacion incluso si la app esta suspendida
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
let destination = modelDirectory.appendingPathComponent("model-new.gguf")
try? FileManager.default.moveItem(at: location, to: destination)
if verifyHash(destination, expected: pendingUpdate.sha256) {
// El intercambio ocurrira en el siguiente inicio de sesion
UserDefaults.standard.set(pendingUpdate.version, forKey: "pending_model_version")
} else {
try? FileManager.default.removeItem(at: destination)
}
}Android: WorkManager
class ModelDownloadWorker(
context: Context, params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val url = inputData.getString("url") ?: return Result.failure()
val expectedHash = inputData.getString("hash") ?: return Result.failure()
val tempFile = File(applicationContext.cacheDir, "model-new.gguf")
// Descargar
downloadFile(url, tempFile) { progress ->
setProgress(workDataOf("progress" to progress))
}
// Verificar
if (tempFile.sha256() != expectedHash) {
tempFile.delete()
return Result.failure()
}
// Preparar para intercambio
val destination = File(applicationContext.filesDir, "model-pending.gguf")
tempFile.renameTo(destination)
return Result.success()
}
}
// Programar la descarga
fun scheduleModelDownload(url: String, hash: String) {
val request = OneTimeWorkRequestBuilder<ModelDownloadWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // Solo WiFi
.setRequiresStorageNotLow(true)
.build()
)
.setInputData(workDataOf("url" to url, "hash" to hash))
.build()
WorkManager.getInstance(context).enqueue(request)
}Intercambio de modelo
No intercambies el modelo mientras esta cargado. Intercambia en un punto seguro:
Estrategia de intercambio seguro
- La descarga se completa: Nuevo modelo guardado como
model-pending.gguf - En el siguiente lanzamiento de la app (o inicio de siguiente sesion de chat):
a. Descargar modelo actual
b. Renombrar
model-current.ggufamodel-previous.ggufc. Renombrarmodel-pending.ggufamodel-current.ggufd. Cargar nuevo modelo e. Actualizar numero de version almacenado - Si el nuevo modelo falla al cargar: Revertir a
model-previous.gguf
func swapModelIfPending() throws {
let pendingPath = modelDirectory.appendingPathComponent("model-pending.gguf")
let currentPath = modelDirectory.appendingPathComponent("model-current.gguf")
let previousPath = modelDirectory.appendingPathComponent("model-previous.gguf")
guard FileManager.default.fileExists(atPath: pendingPath.path) else { return }
// Descargar modelo actual
engine.unload()
// Rotar archivos
try? FileManager.default.removeItem(at: previousPath) // Eliminar respaldo viejo
try? FileManager.default.moveItem(at: currentPath, to: previousPath) // Respaldar actual
try FileManager.default.moveItem(at: pendingPath, to: currentPath) // Promover pendiente
// Intentar cargar nuevo modelo
do {
try engine.load(at: currentPath.path)
// Exito: actualizar version
UserDefaults.standard.set(pendingVersion, forKey: "model_version")
} catch {
// Rollback
try? FileManager.default.removeItem(at: currentPath)
try? FileManager.default.moveItem(at: previousPath, to: currentPath)
try engine.load(at: currentPath.path)
}
}Estrategia de rollback
Siempre mantiene la version anterior del modelo disponible:
- Rollback local: Mantiene
model-previous.ggufen el dispositivo. Si el nuevo modelo falla al cargar o produce calidad pobre, revierte inmediatamente. - Rollback remoto: Incluye URLs de rollback en el manifiesto. Si descubres un problema de calidad del modelo, actualiza el manifiesto para apuntar a la version anterior. Todas las apps se "actualizaran" al modelo antiguo que funciona.
- Rollback automatico: Si la app detecta fallos de inferencia o crashes despues de un intercambio de modelo, revierte automaticamente a la version anterior.
Frecuencia de actualizacion
| Escenario | Frecuencia de actualizacion | Notas |
|---|---|---|
| Producto temprano (iterando rapido) | Semanal-bisemanal | Mejoras rapidas de calidad |
| Producto estable | Mensual-trimestral | Mejoras incrementales |
| Nuevo modelo base disponible | Segun necesidad | Saltos importantes de calidad |
| Datos de entrenamiento cambian significativamente | Segun necesidad | Cambios de dominio |
Cada actualizacion es una ejecucion de fine-tuning ($5-50) mas distribucion CDN. El costo es minimo comparado con la mejora de calidad.
Costos de infraestructura
| Usuarios | Descargas/mes | Tamano del modelo | Costo CDN (Cloudflare R2) |
|---|---|---|---|
| 1,000 | ~200 (actualizaciones + nuevos usuarios) | 1.7GB | ~$0.01/mes |
| 10,000 | ~2,000 | 1.7GB | ~$0.05/mes |
| 100,000 | ~20,000 | 1.7GB | ~$0.51/mes |
Con los precios sin egreso de Cloudflare R2, la entrega OTA de modelos es esencialmente gratuita. Incluso con 100K usuarios, el costo CDN es menor a $1/mes.
El paso de fine-tuning y exportacion GGUF es donde plataformas como Ertas agilizan el flujo de trabajo. Re-entrena con datos actualizados, exporta GGUF, sube al CDN, actualiza el manifiesto. Tus usuarios obtienen el modelo mejorado automaticamente.

