decidealot: Deja de Pedirle una Redacción a un Chatbot Cuando Solo Necesitabas un Puto Sí o No

TypeSafe sacó Jev y mi feed entero perdió la puta cabeza. Lo llaman un modelo System One: le pasas un estado hecho un desastre más unas cuantas preguntas tipadas, y te devuelve una opción, una puntuación o un sí/no con una probabilidad de verdad pegada. Sin redacciones, sin “¡Claro! Aquí tienes el JSON que pediste:”, sin volver a parsear prosa para sacar la única palabra que querías. Hay que reconocerlo, es la puta idea correcta.

Luego lees la letra pequeña. API alojada. Acceso anticipado. Lista de espera. Sin pesos. Y lo que querrías que juzgara es, por definición, lo delicado, “este agente está a punto de borrar la tabla de clientes”, así que tiene que salir de tu red y esperar en la cola de otro antes de que te llegue tu sí o tu no. Ni de coña.

Así que hice lo mismo que ya había hecho con talkies para la voz, flickies para el vídeo y predictalot para las predicciones. Repasé los modelos abiertos que hacen este trabajo, me quedé con los dos mejores, Laya y Von, y los clavé en una sola imagen de Docker detrás de una sola API. Eso es decidealot, el Jev offline: la misma forma de petición y respuesta de System One, MCP en el mismo puerto, tu hardware, sin factura de la nube, sin lista de espera, y lo que estás consultando no sale nunca de tu máquina. Luego lo metí a empujones en aigate justo al lado de sus hermanos, así que si ya tienes ese stack montado, decidealot te queda a una variable de entorno de distancia.

Pedirle una Decisión a un Generador de Texto Es una Puta Gilipollez

Jev reventó porque lo que hay ahora mismo es tonto de cojones:

  • LLMs como clasificadores. Un modelo hecho para generar texto, generando texto, que luego tú parseas para sacar la única palabra que querías. Suplicas “SOLO JSON válido, sin explicaciones” y te llega envuelto en bloques de markdown con una notita muy servicial sobre su razonamiento, más esa vez de cada cincuenta en la que se inventa una cuarta categoría que tú nunca pusiste. “Responde solo con JSON” no es un contrato, es una plegaria, y los modos de salida estructurada solo trasladan el parseo al sampler de otro. Sigues pagando decodificación token a token para producir una etiqueta.
  • Confianza autodeclarada. Pregúntale a un chatbot lo seguro que está y te suelta “85%”, un número que se ha sacado del culo sin pestañear porque en ese hueco quedaba bonito un número. Ese número no ha pasado ni de lejos por un softmax. No puedes ponerle un umbral, no puedes calibrarlo y no puedes meterlo en un log de auditoría y defenderlo después.
  • Latencia tirada a la basura. Cada “¡Por supuesto! Según el contexto proporcionado” es tiempo de reloj metido entre tu evento y tu decisión.
  • Los modelos de pesos abiertos. Los dos mejores que hacen este trabajo en local son Laya, de NandhaKishorM, y Von, de wfzyx, con los pesos bajo Apache 2.0. Genial. Cada uno trae su propio servidor con sus propias manías sobre cómo tiene que ser una petición, su propia instalación y su propio runtime de Torch, y ninguno de los dos acepta tal cual la petición oficial de TypeSafe. ¿Quieres los dos? Pues te comes dos servidores, dos instalaciones y dos runtimes de Torch, y el pegamento lo escribes tú.

Yo quería un solo servicio que arranco una vez y al que apunto todo. El contrato de la versión alojada, para que el código escrito contra él no tenga que aprenderse una segunda API. Los dos modelos locales detrás. MCP para los agentes. Y nada de tener un modelo cargado y un runtime de Torch entero chupando RAM mientras nadie le pregunta una mierda.

Tres Tipos de Pregunta, Una Sola Petición

El contrato es pequeño. Mandas un model, un state y un mapa de questions con nombre. El estado es lo que quieras que se juzgue: un string, un objeto JSON o un array. Los nombres de las preguntas los pones tú y vuelven como claves dentro de answers. Cada pregunta es de uno de estos tres tipos:

  • choice elige una etiqueta de entre las claves de criteria. Esas claves son las únicas respuestas posibles. El modelo no puede inventarse un cuarto cajón, porque no está escribiendo ni una puta palabra. Recibes la choice, una confidence y una probabilidad para cada etiqueta.
  • score recibe un array ordenado de criterios en el que la posición es la puntuación, empezando por 0. Devuelve una puntuación esperada, así que 1.9 en una rúbrica de tres niveles es una respuesta de verdad que significa “blocking, con una pizca de soon”, más las probabilidades de cada nivel y una legend que traduce las posiciones de vuelta a tus propias palabras.
  • noul es sí o no. Un solo campo, noul, la probabilidad de que la afirmación sea cierta. No hay campo de confianza aparte, porque ese número ya es la confianza.

Mete los tres en una sola petición y se responden todos contra el mismo estado:

curl --fail http://127.0.0.1:8080/v1/systemone \
  --header 'Content-Type: application/json' \
  --data '{
    "model": "laya",
    "state": "You billed me twice for March. Refund the duplicate today or I am cancelling.",
    "questions": {
      "department": {
        "type": "choice",
        "instructions": "Which team should handle this?",
        "criteria": {
          "billing": "Invoices, payments, refunds.",
          "technical": "Bugs, outages, errors.",
          "other": "Everything else."
        }
      },
      "urgency": {
        "type": "score",
        "criteria": ["not urgent", "soon", "blocking"]
      },
      "churn_risk": {
        "type": "noul",
        "instructions": "Does the customer threaten to leave?"
      }
    }
  }'

Y no es un ejemplo inventado. Esto es lo que Laya devolvió de verdad, corriendo en la imagen CUDA detrás de mi propio aigate:

{
  "model": "laya",
  "answers": {
    "department": {
      "type": "choice",
      "choice": "billing",
      "confidence": 0.8055,
      "probabilities": { "billing": 0.9543, "technical": 0.0317, "other": 0.014 }
    },
    "urgency": {
      "type": "score",
      "score": 1.8986,
      "confidence": 0.7053,
      "legend": { "0": "not urgent", "1": "soon", "2": "blocking" },
      "probabilities": { "0": 0.0222, "1": 0.0571, "2": 0.9208 }
    },
    "churn_risk": {
      "type": "noul",
      "noul": 0.2673
    }
  },
  "usage": { "input_tokens": 156, "output_tokens": 0 }
}

Mira output_tokens. Cero. Laya no escribió ni un token, así que no hay nada que pueda volver como “¡Claro! Aquí tienes”. La misma petición a Von reporta tres, que sigue estando a años luz de un párrafo. En los dos casos la forma de la respuesta la fija el schema, no si hoy al modelo le salió de los cojones hacerle caso a tus instrucciones.

Ahora mira churn_risk. El cliente escribió literalmente “or I am cancelling” y Laya puso la amenaza en 0.27. Le mandé la misma petición a Von como segunda opinión y Von dijo 0.23. Billing y blocking, los dos clavados. La amenaza de darse de baja, los dos pasaron olímpicamente. Eso no es decidealot destrozando nada, es lo que respondieron los modelos, y es la razón entera de que exista el párrafo siguiente. Prueba tus preguntas con tus propios casos antes de colgarles un umbral. Reformular la pregunta sale barato. Enterarte en producción, no.

decidealot te da los números y ahí se queda. No actúa. El umbral es cosa de tu código: permitir cuando allow pase de 0.95, mandar todo lo demás a la cola de un humano, guardar la respuesta entera junto al registro de la acción. La misma postura que predictalot, solo que una capa más allá. Entran números, y la decisión de qué hacer con ellos sigue siendo tuya.

Dos Modelos, Diez Selectores, Uno en Memoria

Cada petición nombra un modelo. No hay valor por defecto, así que tus decisiones no cambian de cerebro en silencio porque algún gilipollas tocó una variable de entorno. GET /v1/models devuelve el catálogo, y son estos diez:

  • laya, laya-auto, laya-latest: Laya con enrutado automático de checkpoint. Mira el sistema de escritura y el idioma del estado y elige por su cuenta el checkpoint inglés o el multilingüe.
  • laya-english: el checkpoint inglés, para inglés en alfabeto latino.
  • laya-multilingual: el checkpoint multilingüe, para todo lo demás, incluido el texto corto en alfabeto latino que no sea claramente inglés.
  • laya-typed-decisions: el checkpoint ajustado para llamadas estructuradas repetidas dentro de un flujo de trabajo, como políticas, enrutado, triaje y aprobaciones. Pruébalo con tus propios casos antes de fiarte de él.
  • von, von-latest, von-1.1, von-1.1.0: Von, un modelo independiente, solo en inglés, para decisiones cortas y bien planteadas. Útil por sí solo, y útil como segunda opinión antes de estandarizar un flujo de trabajo sobre Laya.

Laya es una familia de modelos con tres checkpoints. Von es otro modelo distinto, hecho por otra gente. Los dos aceptan la misma petición y devuelven los mismos tipos de respuesta, que es justo la gracia de meterlos detrás de un solo contrato: cambias el string de model y comparas.

Descargar un Modelo Es Matar el Proceso

Esta es la parte que de verdad me importa. En la imagen hay tres virtualenvs de Python. /opt/app-venv es el gateway: FastAPI, httpx, el SDK de MCP, pydantic, uvicorn. Nada de Torch, ni un solo import en todo el código del gateway. /opt/laya-venv y /opt/von-venv tienen cada uno el stack de un modelo. Ahora mismo los dos fijan casualmente las mismas versiones de torch y transformers, así que esto no es un apaño para una pelea que ya esté pasando. Significa que una actualización de Laya nunca puede meter mano en el entorno de Von, y que el proceso que responde a tus peticiones HTTP nunca tiene un modelo dentro.

Cada modelo corre como proceso hijo del gateway, arrancado por un supervisor desde un comando fijo sin shell, escuchando en un puerto fijo de loopback. Solo hay uno residente a la vez. Pide Von con Laya en memoria y el supervisor espera a que termine cada petición de Laya que esté en curso, mata a Laya, arranca Von y consulta el /health de Von cada cuarto de segundo hasta que contesta. Moverse entre los selectores de Laya no sale del proceso de Laya, porque son checkpoints del mismo modelo. Recortado al mínimo, el candado queda así:

async with self._provider_switch_condition:
    spec = self._require_spec(provider_name)
    while self._has_active_other_provider(provider_name):
        await self._provider_switch_condition.wait()
    await self._unload_other_idle_providers_locked(provider_name)
    await self._start_provider_locked(spec)
    self._active_requests[provider_name] += 1
try:
    yield
finally:
    async with self._provider_switch_condition:
        self._active_requests[provider_name] -= 1
        self._last_used_at[provider_name] = asyncio.get_running_loop().time()
        self._provider_switch_condition.notify_all()

¿Por qué un proceso entero en lugar de del model y rezarle al recolector de basura? Porque eso no devuelve la memoria. El caching allocator de PyTorch se aferra a lo que ya pilló, y el contexto de CUDA sigue ahí mientras viva el proceso. Matar el proceso es la única descarga que descarga de verdad, así que eso es exactamente lo que hace la descarga: terminate, diez segundos de cortesía, y luego kill si el proceso se pone gilipollas. Los pesos, las reservas de memoria de Torch, los hilos de trabajo y el contexto de CUDA se van todos con él. La siguiente petición lo vuelve a arrancar.

Ese arranque no sale gratis, y no voy a hacer como que sí. En mi máquina con GPU, a través de aigate, la primera petición a Laya después de un arranque en frío tardó unos 61 segundos, porque el proceso tenía que levantarse y cargar el modelo. La siguiente petición idéntica volvió en 55 milisegundos de punta a punta, red incluida, con la misma respuesta byte a byte. Cambiar a Von tardó unos dos minutos, porque primero había que tumbar Laya y luego levantar Von. Von en caliente respondió en unos 115 milisegundos. Así que el timeout de inactividad es un trade-off de verdad: ponlo lo bastante largo para que tu tráfico no se pase la vida pagando el arranque en frío, y lo bastante corto para que la GPU no se quede de niñera de un modelo que no usa nadie.

Hay dos cosas que disparan la descarga. Un reaper de inactividad descarga el proveedor que lleva sin usarse DECIDEALOT_PROVIDER_IDLE_UNLOAD_SECONDS, 600 por defecto, y lo comprueba cada décima parte de esa ventana, con el intervalo acotado entre 10 milisegundos y 30 segundos. Ponlo a 0 y lo único que se apaga es el temporizador. Y POST /v1/models/unload lo hace bajo demanda. Esa ruta es todo o nada: si algún proveedor está en mitad de una petición te comes un 409 PROVIDER_BUSY y no se libera nada. Jamás le arranca un modelo de las manos a quien lo está usando.

Bájalo Una Vez y Adiós al Hub

Montas un directorio del host en /models, y decidealot gestiona /models/laya y /models/von ahí dentro. En el primer arranque el supervisor ejecuta un paso prepare para cada modelo dentro del venv de ese mismo modelo, que se baja el snapshot de Hugging Face, convaiinnovations/laya y wfzyx/von, cada uno fijado a una revisión exacta de commit. Luego comprueba que cada fichero que necesita el runtime está de verdad ahí: cuatro ficheros en cada uno de los tres directorios de checkpoint de Laya, seis para Von. Nada de eso importa Torch. /health responde 503 hasta que los dos bundles están listos, que son unos 5.3 GB y unos cuantos minutos la primera vez. Después los ficheros ya están ahí y no se baja nada.

Y antes de cargar un modelo, decidealot pone HF_HUB_OFFLINE=1 y TRANSFORMERS_OFFLINE=1. Con el bundle ya verificado, ningún modelo puede darse un paseo de vuelta al Hub a mitad de ejecución y traerse alguna mierda que tú no fijaste.

Cómo Hacer que Dos Servidores Upstream Hablen un Solo Contrato

El schema oficial dice que instructions es opcional y puede ser un valor JSON anidado, y lo mismo vale para los valores de los criterios. Laya quiere un campo instructions en todas y cada una de las preguntas. Von quiere que sea un string. Mándale a cualquiera de los dos una petición que la API oficial acepta sin rechistar y te la rechaza por una diferencia de forma que nadie pidió.

Así que decidealot valida primero tu petición contra los modelos de datos oficiales de la petición, y luego construye a partir de ella el cuerpo nativo de cada modelo: un instructions que falta se convierte en un string vacío, y los valores anidados se renderizan como texto JSON compacto. Tus etiquetas de choice, tu orden de score y tu state pasan sin que nadie los toque. A la vuelta, la respuesta del proveedor se valida contra el schema de respuesta oficial. La basura que solo mete el proveedor, como el bloque de enrutado de Laya, se tira, y model se rellena con el nombre público de lo que haya respondido de verdad. Si un proveedor devuelve algo que no encaja en el schema, te llega un 503 limpio en lugar de basura disfrazada de decisión. Si un proveedor rechaza una petición con un mensaje pelado, ese mensaje se reescribe como el sobre de validación oficial {"detail": [...]}, así que tu manejo de errores solo ve una forma, siempre la misma.

Y luego está el servidor de Von, que encuentra su backend a través de un singleton de todo el proceso cuyo constructor público no tiene manera de enterarse de dónde vive el checkpoint. Así que decidealot construye el motor por su cuenta y lo encaja a presión en el hueco del que lee el servidor:

engine = engine_type(
    backend_name=os.environ.get(_von_backend_env, _default_von_backend),
    device=os.environ.get(_von_device_env),
)
engine.backend = option_marker_backend_type(checkpoint_dir=str(model_dir), device=engine.device)
# Von's server resolves its backend from this singleton, whose public constructor
# has no checkpoint-directory argument.
with engine_type._lock:
    engine_type._instance = engine

Sí, eso es meterle mano a un singleton privado. Es feo de cojones, y es exactamente tan feo como hace falta para apuntar Von a un directorio que controlas tú.

MCP en el Mismo Puerto

El mismo container sirve MCP Streamable HTTP en /mcp, con tres herramientas: system_one, list_models y unload_models. Pasan por el mismo servicio de decisión que las rutas REST, o sea, la misma validación, el mismo supervisor, el mismo límite de cuerpo y el mismo bearer token. system_one recibe exactamente el model, el state y las questions que mandarías por POST, y devuelve el mismo resultado estructurado, así que un agente puede leer las probabilidades antes de decidir qué hace después. Un fallo de validación vuelve como isError: true con el cuerpo detail de TypeSafe en el texto, así que el agente ve en qué la ha cagado en lugar de un “error” pelado.

Ninguna herramienta acepta una URL, una ruta del sistema de ficheros, un ejecutable, un directorio de modelo ni una opción de runtime. El router de modelos asocia cada alias a un endpoint fijo de loopback, y quien llama nunca llega a elegir un destino de red. Lo máximo que puede hacer aquí un agente es elegir un nombre del catálogo.

Ponerlo detrás de un reverse proxy o de un túnel no significa apagar la protección contra DNS rebinding. Metes en la lista blanca el Host público exacto en DECIDEALOT_MCP_ALLOWED_HOSTS y, para clientes de navegador, el origin exacto en DECIDEALOT_MCP_ALLOWED_ORIGINS. Las comprobaciones van en un orden fijo: un bearer que falta o está mal se lleva primero un 401, luego un host desconocido se lleva un 421, y luego un origin de navegador desconocido se lleva un 403. No metas comodines en esas listas en una máquina expuesta a internet. Pon DECIDEALOT_API_KEY a un secreto de verdad antes de que el container se acerque siquiera a una dirección pública.

La Mierda Aburrida que Te Deja Tenerlo Corriendo Sin Miedo

  • Un container que no puede hacer gran cosa. La ejecución documentada lleva sistema de ficheros raíz de solo lectura, --cap-drop ALL, no-new-privileges, tmpfs noexec para /tmp y /var/run, un límite de pids, un tope de memoria y un puerto publicado solo en loopback.
  • Tu UID, no el de la imagen. El container corre con el --user que le pases, así que el directorio de modelos que acabas de crear con mkdir tiene permisos de escritura sin el bailecito del chown. La imagen solo cae al usuario sin root 1000:1000 cuando no dices nada.
  • Un solo agujero de exec para CUDA, y ni uno más. Triton compila pequeños helpers de CUDA en tiempo de ejecución y tiene que cargarlos desde algún sitio, así que la ejecución con CUDA añade un único tmpfs exec en /var/cache. Todo lo demás sigue en solo lectura y noexec.
  • Auth bearer que no filtra tiempos. Opcional, apagada salvo que definas DECIDEALOT_API_KEY, y el token se compara con hmac.compare_digest sobre bytes codificados. /health se queda abierto para tu liveness probe.
  • Un tope para el cuerpo. 1 MiB por defecto vía DECIDEALOT_MAX_REQUEST_BYTES, y cualquier cosa que declare un tamaño mayor se lleva un 413 antes de que un modelo llegue a verla.
  • IDs de petición que puedes buscar con grep. Manda un UUID o un ULID en X-Request-Id y el ID acompaña a la petición por todos los logs. Manda basura o nada y decidealot genera un UUID. En los dos casos el ID vuelve en la respuesta.
  • Una barrera de supply chain para la que los modelos eran demasiado jóvenes. Las dependencias del gateway están detrás de una barrera de antigüedad exclude-newer de uv, y los stacks de los modelos se instalan con --require-hashes desde ficheros bloqueados por hash. Los dos paquetes de modelos son más jóvenes de lo que permite la barrera. La release fijada de Laya ni siquiera está en PyPI, así que se instala desde el tarball del commit upstream con el SHA-256 en el lock. Cada paquete lleva una excepción por escrito y aprobada por el dueño. La barrera hizo su trabajo y yo firmé la autorización, como un padre antes de una excursión.
  • Tests con listón mínimo. Cobertura de ramas con un mínimo duro del 90%, más ejecuciones HTTP reales contra los pesos de CPU y de CUDA ya bajados.

Dos imágenes. La de CPU ronda el medio giga comprimida en Docker Hub y es la opción por defecto sensata. La de CUDA ronda los 9 GB, está construida sobre CUDA 12.6, solo amd64, y necesita el NVIDIA Container Toolkit y --gpus all. Tira de ella solo cuando la velocidad y la memoria del modelo justifiquen de verdad montar la GPU.

Arráncalo

model_directory="$HOME/.local/share/decidealot/models"
mkdir --parents "$model_directory"
docker run --detach --name decidealot --init --restart unless-stopped \
  --user "$(id -u):$(id -g)" \
  --read-only --cap-drop ALL --security-opt no-new-privileges:true \
  --pids-limit 512 --memory 8g --cpus 4 \
  --tmpfs /tmp:rw,noexec,nosuid,size=128m \
  --tmpfs /var/run:rw,noexec,nosuid,size=8m \
  --mount type=bind,source="$model_directory",target=/models \
  --publish 127.0.0.1:8080:8080 \
  psyb0t/decidealot:latest
curl --fail http://127.0.0.1:8080/health

Espera a que /health se ponga en verde y luego manda la petición de arriba. Para la GPU, añade --gpus all, el tmpfs de /var/cache, y usa psyb0t/decidealot:latest-cuda. El documento de despliegue del repo tiene la receta completa de CUDA.

Si ya tienes aigate, sáltate toda esa mierda. Pon DECIDEALOT=1 para el servicio de CPU en /decidealot/ o DECIDEALOT_CUDA=1 para el de GPU en /decidealot-cuda/, con MCP debajo de cada uno, y las herramientas se suman además al /mcp/ agregado de aigate, junto a todo lo demás con lo que ya hablan tus agentes. Los dos servicios pueden correr a la vez y compartir un solo directorio de modelos, así que los 5.3 GB caen en disco una sola vez.

aigate además te gestiona la memoria sin que tengas que estar encima. Antes de que cualquier otro modelo local (Ollama, sd.cpp, talkies, vLLM, llama.cpp) corra en el mismo hardware, el gestor de recursos de aigate le dice a decidealot que descargue. Las decisiones que entran por el MCP de aigate cogen el mismo lock de hardware que todo lo demás, y POST /v1/unload/cuda o /v1/unload/cpu descarga decidealot junto con el resto del stack. Una decisión que está en curso responde 409 y conserva su modelo hasta que salte el temporizador de inactividad, así que a nadie le arrancan nada de las manos a mitad de llamada. El único agujero: una petición mandada directamente a /decidealot/ no desaloja a nadie más, así que por esa ruta el malabarismo con la GPU sigue siendo cosa tuya.

Instalarlo en Tu Agente

Un agente que está a punto de actuar según una probabilidad debería al menos saber qué significa esa probabilidad. La skill le explica cómo desplegar el container, elegir Laya o Von, mandar una decisión tipada, leer las probabilidades sin tomarlas por un permiso y usar MCP directamente. Todo lo que hay bajo .agents/ está catalogado en un solo marketplace, así que son dos comandos:

claude plugin marketplace add psyb0t/agents
claude plugin install decidealot@psyb0t

Codex usa el mismo marketplace con otro verbo, codex plugin add decidealot@psyb0t, porque codex plugin install no existe. OpenClaw recibe la skill y, opcionalmente, un puente stdio para clientes que solo saben hablar con un servidor MCP stdio local. El puente reenvía todo al container que ya tienes corriendo:

openclaw skills install @psyb0t/decidealot
openclaw plugins install clawhub:@psyb0t/decidealot

Decide la Máquina. Actúas Tú.

Jev tenía la idea buena, pero en casa ajena. decidealot es la misma idea en la tuya: un conjunto cerrado de respuestas, una probabilidad de verdad en cada una y cero posibilidades de que te devuelva una redacción. No sabe cuál debería ser tu umbral, y tampoco hace como que lo sabe. Tú eliges el corte según lo que te cuesta equivocarte, y en eso el modelo no tiene ni voz ni voto.

Píllalo en github.com/psyb0t/decidealot o bájate psyb0t/decidealot de Docker Hub. El código es WTFPL, así que haz con él lo que te salga de los cojones. Laya, Von, PyTorch, Transformers y los pesos que te bajas mantienen cada uno su propia licencia, así que léetelas antes de atornillar un modelo en algo que vendas. Y ahora deja de pedirle a un chatbot un sí o un no.