Entrenar un modelo es solo la mitad del trabajo: para aportar valor tienes que desplegar modelos de ML en dispositivos edge, mantenerlos actualizados y saber en todo momento qué versión ejecuta cada dispositivo. Esta guía muestra un patrón probado para empaquetar, versionar y desplegar modelos en una flota, usando MQTT para señalar las actualizaciones y recoger el estado mientras los archivos del modelo viajan por HTTPS.
Por qué es difícil desplegar modelos en el edge
Llevar un modelo a un servidor es un único despliegue. Llevarlo a cientos de gateways, cámaras o microcontroladores es un problema de sistemas distribuidos:
- Los dispositivos se desconectan en momentos aleatorios y deben ponerse al día al reconectarse.
- El hardware es diverso, así que un mismo artefacto de modelo puede no servir para todos los dispositivos.
- Un modelo defectuoso puede degradar las predicciones en silencio en lugar de fallar, por lo que necesitas señales de salud más allá de "el proceso está en marcha".
- El ancho de banda y el almacenamiento son limitados, y algunas instalaciones están detrás de cortafuegos que solo permiten conexiones salientes.
MQTT encaja bien con estas restricciones. Los dispositivos mantienen una única conexión saliente con un broker, los mensajes retenidos entregan al instante la última instrucción a quienes se incorporan tarde y un mensaje Last Will te avisa cuando un dispositivo se desconecta. Si este reparto de responsabilidades es nuevo para ti, nuestra panorámica de qué hace un broker MQTT es una buena introducción.
Paso 1: empaquetar el modelo como artefacto versionado
Trata cada modelo como una versión inmutable, nunca como un archivo que se sobrescribe. Un paquete de modelo debería contener:
| Archivo | Finalidad |
|---|---|
model.tflite (o .onnx, _edgetpu.tflite, .engine) |
El modelo compilado para un runtime concreto |
labels.txt o classes.json |
Nombres de las clases de salida en el orden correcto |
manifest.json |
Versión, runtime, forma de la entrada, preprocesamiento, hardware de destino, checksum |
preprocess.json (opcional) |
Constantes de normalización, modo de redimensionado, orden de canales |
Las discrepancias de preprocesamiento son la causa más frecuente de "el modelo funciona en mi portátil pero no en el dispositivo". Registrar en el manifiesto el tamaño de entrada esperado, el orden de color y la normalización, y hacer que el dispositivo lo compruebe al cargar, evita toda una categoría de errores silenciosos.
Elige el formato según el destino:
| Hardware de destino | Formato típico | Notas |
|---|---|---|
| Microcontrolador (ESP32-S3, Cortex-M) | TensorFlow Lite Micro, int8 | Normalmente compilado dentro del firmware; se actualiza mediante OTA de firmware |
| Raspberry Pi / gateway ARM | TFLite u ONNX Runtime | Se puede sustituir en tiempo de ejecución como archivo |
| Coral Edge TPU | _edgetpu.tflite |
Debe compilarse con el compilador de Edge TPU |
| NVIDIA Jetson | Motor TensorRT u ONNX | Los motores dependen de la GPU y de la versión de TensorRT, así que compílalos por tipo de dispositivo |
En los microcontroladores, el modelo suele vivir dentro de la imagen del firmware, así que actualizar el modelo es actualizar el firmware. Nuestro tutorial de TensorFlow Lite Micro cubre ese caso; el resto de esta guía se centra en dispositivos de clase Linux que cargan los modelos desde disco.
Paso 2: versionar semánticamente y firmar cada versión
Usa una cadena de versión que diga algo a los operadores, como defect-classifier 2.3.0, donde el número mayor cambia cuando las entradas o salidas cambian de forma incompatible. Junto a la versión, calcula un checksum SHA-256 del artefacto y publícalo en el manifiesto. Los dispositivos deben rechazar cualquier archivo cuyo checksum no coincida. Para una garantía mayor, firma el manifiesto con una clave privada custodiada por tu sistema de CI y distribuye solo la clave pública a los dispositivos.
Guarda los artefactos en un almacenamiento de objetos con HTTPS o en un servidor de archivos interno. MQTT es el canal equivocado para binarios de varios megabytes: el broker tendría que mantener payloads grandes en memoria y muchos planes limitan el tamaño de los mensajes. El plan Serverless gratuito de ConnectMQ, por ejemplo, limita los mensajes a 256 KB, de sobra para un manifiesto pero no para un modelo de visión.
Paso 3: señalar las actualizaciones por MQTT
El plano de control usa tres topics por dispositivo (o por grupo de dispositivos):
fleet/<site>/<device>/model/desired retained, written by the deployment service
fleet/<site>/<device>/model/reported retained, written by the device
fleet/<site>/<device>/model/events not retained, progress and errors
El servicio de despliegue publica un mensaje de estado deseado, retenido para que un dispositivo desconectado lo reciba en cuanto se conecte:
{
"model": "defect-classifier",
"version": "2.3.0",
"url": "https://models.example.com/defect-classifier/2.3.0/model.tflite",
"sha256": "9f2c4c1d5e8b0a7f3d6e1b2c4a5f6e7d8c9b0a1f2e3d4c5b6a7f8e9d0c1b2a3f",
"runtime": "tflite",
"rollout": "canary",
"issuedAt": "2026-10-16T08:00:00Z"
}
El dispositivo compara el estado deseado con lo que ejecuta, descarga, verifica, sustituye e informa:
{
"model": "defect-classifier",
"version": "2.3.0",
"state": "active",
"previous": "2.2.1",
"loadedAt": "2026-10-16T08:03:12Z",
"selfTest": "passed"
}
Este patrón de estado deseado/informado es la misma idea que usan las sombras de dispositivo (device shadows) y funciona con cualquier broker que cumpla el estándar.
Paso 4: un agente de actualización mínimo para el dispositivo
El agente siguiente se ejecuta en un dispositivo edge con Linux. Se suscribe a su topic de estado deseado, descarga el modelo, verifica el checksum, ejecuta una prueba rápida, sustituye el modelo activo de forma atómica y conserva la versión anterior para poder revertir.
import hashlib
import json
import os
import shutil
import time
import urllib.request
from pathlib import Path
import paho.mqtt.client as mqtt
DEVICE = "gw-07"
BASE = f"connectmq/demo/a91e4d/fleet/plant1/{DEVICE}/model"
MODELS = Path("/opt/models")
ACTIVE = MODELS / "active" # enlace simbólico al directorio de la versión activa
STATE_FILE = MODELS / "state.json"
def current_version():
try:
return json.loads(STATE_FILE.read_text())["version"]
except (FileNotFoundError, KeyError, json.JSONDecodeError):
return None
def report(client, state, **extra):
body = {"model": extra.pop("model", None), "state": state,
"version": extra.pop("version", current_version()),
"ts": int(time.time() * 1000), **extra}
client.publish(f"{BASE}/reported", json.dumps(body), qos=1, retain=True)
def event(client, msg):
client.publish(f"{BASE}/events", json.dumps({"msg": msg, "ts": int(time.time() * 1000)}), qos=0)
def sha256_of(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def smoke_test(model_path):
# Carga el modelo y ejecuta una inferencia con una entrada de muestra guardada.
# Sustitúyelo por tu runtime, p. ej. tflite_runtime.interpreter.Interpreter.
return model_path.stat().st_size > 0
def apply_update(client, desired):
version = desired["version"]
if version == current_version():
report(client, "active", model=desired["model"], version=version)
return
target = MODELS / version
target.mkdir(parents=True, exist_ok=True)
tmp = target / "model.tmp"
event(client, f"downloading {version}")
urllib.request.urlretrieve(desired["url"], tmp)
if sha256_of(tmp) != desired["sha256"]:
tmp.unlink(missing_ok=True)
report(client, "failed", model=desired["model"], reason="checksum mismatch")
return
final = target / "model.tflite"
tmp.replace(final)
if not smoke_test(final):
report(client, "failed", model=desired["model"], reason="smoke test failed")
return
previous = current_version()
link_tmp = MODELS / "active.tmp"
if link_tmp.exists() or link_tmp.is_symlink():
link_tmp.unlink()
link_tmp.symlink_to(target)
os.replace(link_tmp, ACTIVE) # sustitución atómica del enlace simbólico
STATE_FILE.write_text(json.dumps({"version": version, "previous": previous}))
report(client, "active", model=desired["model"], version=version, previous=previous)
# Conserva en disco solo la versión actual y la anterior
for d in MODELS.iterdir():
if d.is_dir() and not d.is_symlink() and d.name not in (version, previous):
shutil.rmtree(d, ignore_errors=True)
def on_connect(client, userdata, flags, reason_code, properties):
client.subscribe(f"{BASE}/desired", qos=1)
report(client, "online")
def on_message(client, userdata, msg):
try:
apply_update(client, json.loads(msg.payload))
except Exception as exc: # informa de cada fallo en lugar de morir en silencio
report(client, "failed", reason=str(exc))
MODELS.mkdir(parents=True, exist_ok=True)
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"{DEVICE}-a91e4d")
client.on_connect = on_connect
client.on_message = on_message
client.will_set(f"{BASE}/reported", json.dumps({"state": "offline"}), qos=1, retain=True)
client.connect("broker.connectmq.com", 1883, keepalive=60)
client.loop_forever()
Ten en cuenta que la descarga se ejecuta dentro del callback del mensaje, lo que bloquea el bucle de red. Es aceptable para modelos pequeños y un entorno de pruebas; en producción, delega el trabajo en un hilo worker para que los keepalives continúen durante las descargas largas. Tu proceso de inferencia debe abrir el modelo a través del enlace simbólico active y recargarlo cuando cambie, por ejemplo vigilando el archivo de estado.
Para disparar un despliegue durante las pruebas, publica un mensaje de estado deseado retenido:
mosquitto_pub -h broker.connectmq.com -p 1883 -i deployer-a91e4d -r -q 1 \
-t "connectmq/demo/a91e4d/fleet/plant1/gw-07/model/desired" \
-f desired.json
El servidor MQTT público gratuito sirve para probar el flujo y para prototipos, no para producción, pero recuerda que cualquiera puede leerlo, así que nunca pongas allí URL reales de modelos ni tokens firmados.
Paso 5: desplegar por fases
Nunca envíes un modelo nuevo a toda la flota de golpe. Un despliegue por fases tiene este aspecto:
- Modo sombra. El nuevo modelo se ejecuta junto al antiguo en unos pocos dispositivos y se publican ambas salidas, pero solo el antiguo dispara acciones. Compáralas fuera de línea.
- Canary. Publica el estado deseado para un grupo reducido de dispositivos. Vigila su estado informado, la latencia de inferencia y la distribución de las predicciones.
- Oleadas progresivas. Amplía a grupos más grandes, con pausas entre oleadas. Un script de despliegue simplemente escribe el topic de estado deseado para el siguiente lote de dispositivos.
- Despliegue completo. Cuando la última oleada esté sana, convierte la nueva versión en la predeterminada para los dispositivos que se aprovisionen a partir de ese momento.
- Reversión. Si las métricas empeoran, publica la versión anterior como estado deseado. Como el dispositivo conserva la última versión en disco, la reversión ni siquiera necesita una descarga.
La pertenencia a los grupos puede residir en tu servicio de despliegue, o puedes codificarla en los topics, por ejemplo fleet/plant1/canary/model/desired, al que un dispositivo del grupo canary se suscribe además de a su propio topic.
Monitorizar qué ejecuta cada dispositivo
Como cada dispositivo publica un mensaje reported retenido, una sola suscripción con comodín a fleet/+/+/model/reported proporciona un inventario completo de la flota en el momento de conectarse. Combínalo con la telemetría de predicciones para vigilar la deriva: si la distribución de clases o las puntuaciones de confianza cambian bruscamente tras una actualización, probablemente el modelo esté viendo entradas con las que no se entrenó. Nuestro artículo sobre MLOps para IoT profundiza en la monitorización y los ciclos de reentrenamiento.
Lista de comprobación para producción
- Los artefactos son inmutables, están versionados y se almacenan en HTTPS.
- Cada manifiesto incluye un checksum SHA-256 e, idealmente, una firma.
- Los dispositivos verifican, hacen una prueba rápida y sustituyen de forma atómica, conservando una versión anterior.
- Los estados deseado e informado viven en topics MQTT retenidos, con un Last Will para detectar desconexiones.
- Los despliegues pasan por modo sombra, canary, oleadas y despliegue completo, con reversión mediante un solo mensaje.
- Los brokers usan TLS en el puerto 8883 y credenciales por dispositivo.
Para flotas en producción, un broker MQTT gestionado se encarga de los certificados, el escalado y la autenticación para que tu equipo pueda centrarse en los modelos. Con ConnectMQ, cada dispositivo recibe credenciales cuyo client ID está vinculado a él, y el explorador de topics en directo del portal muestra los mensajes de estado deseado e informado a medida que avanza un despliegue. Para el contexto más amplio de cómo combinar modelos y dispositivos conectados, consulta nuestra guía completa de IA e IoT.
Empieza con ConnectMQ
ConnectMQ es un broker MQTT gestionado compatible con MQTT 3.1.1 y 5.0 sobre TCP, TLS y WebSockets, listo para transportar las señales de actualización de tus modelos. Regístrate en el plan Serverless gratuito, sin tarjeta de crédito, y pon en marcha hoy tu primer despliegue.
Preguntas frecuentes
¿Puedo enviar el propio archivo del modelo por MQTT?
Puedes dividir en fragmentos los archivos pequeños, pero rara vez merece la pena. Usa MQTT para la señal de actualización y el estado, y descarga el artefacto por HTTPS, donde las descargas reanudables y la caché son estándar.
¿Cómo recibe la actualización un dispositivo que estaba desconectado?
Como el mensaje de estado deseado es retenido, el broker lo entrega inmediatamente cuando el dispositivo se reconecta y se suscribe. Después, el dispositivo compara las versiones y se actualiza si es necesario.
¿Cómo revierto un modelo defectuoso?
Publica la versión anterior como estado deseado. Un dispositivo que conserva su última versión en disco puede volver a ella al instante sin descargar nada.
¿Los microcontroladores usan el mismo enfoque?
La señalización es la misma, pero el modelo suele estar compilado dentro del firmware, así que el dispositivo realiza una actualización OTA del firmware en lugar de sustituir un archivo de modelo.



