Proyecto: servidor de inferencia
Cargar pesos, servir predicciones por HTTP, medir la concurrencia y desplegar en Docker
Un modelo entrenado en Python no sirve de nada si nadie puede llamarlo. Construirás un servicio en C++ que carga los pesos desde JSON, valida la entrada, responde por HTTP en milisegundos, atiende peticiones concurrentes y se despliega en una imagen Docker mínima, con mediciones y límites explicados.
- Cargar pesos y metadatos de un modelo desde JSON con validación explícita
- Implementar la inferencia de una regresión logística sin dependencias externas
- Exponer endpoints /health y /predict con códigos de estado correctos
- Analizar la concurrencia de un servidor con pool de hilos y medir latencia y throughput
- Construir y ejecutar una imagen Docker de dos etapas
Antes necesitas: Proyecto: motor matricial · Excepciones y std::thread · Archivos, JSON y CSV
En esta lección
- ¿Qué es un servidor de inferencia?
- ¿Para qué sirve?
- Cómo funciona por dentro
- Estructura y dependencias
- Sintaxis: el modelo como contrato de datos
- Ejemplo básico: cargar pesos y predecir sin red
- Ejemplo intermedio: el servidor HTTP
- Visualización: de features a probabilidad
- Visualización: una petición paso a paso
- Caso práctico: concurrencia, carga y Docker
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
Entrenaste el modelo en Python, guardaste los coeficientes y ahora la aplicación necesita una respuesta en pocos milisegundos por petición. Arrancar un intérprete en cada llamada cuesta cientos de milisegundos; dejar un notebook abierto no es un servicio. La pieza que falta es un proceso que permanezca vivo, tenga el modelo en memoria y hable HTTP.
Ese proceso es un servidor de inferencia, y aquí lo construyes completo: carga de pesos, validación, concurrencia, imagen Docker y una medición honesta de lo que aguanta.
¿Qué es un servidor de inferencia?
Es un programa de larga vida que expone un modelo por la red: recibe datos, aplica el modelo entrenado y devuelve una predicción. No entrena, no guarda historial por petición y no sabe quién llama. Es una función pura con interfaz de red, y esa pureza permite atender a muchos clientes a la vez.
Piensa en un mostrador de comida: toma el pedido, la cocina lo prepara y el plato vuelve. Si la cocina se bloquea, la cola crece y el cliente percibe esperas de minutos aunque cada plato tarde lo mismo. Un servidor se juzga por esa cola, no por la operación aislada.
¿Para qué sirve?
- Servir una predicción a una aplicación: el frontend envía features, recibe probabilidad y decide qué mostrar.
- Descargar trabajo de un servicio Python: mover solo el cálculo pesado a C++ y dejar la orquestación donde ya está.
- Evaluar A/B de versiones: dos contenedores con modelos distintos y un proxy que reparte tráfico.
- Aprender los límites reales: latencia, concurrencia y memoria dejan de ser teoría cuando ves la cola crecer.
En un sistema de Machine Learning esto ocupa el último tramo: el training pipeline produce pesos, el registro los versiona y el servidor los carga. El contrato entre ambos es el archivo JSON con su versión, no el código. Si el modelo cambia, la interfaz HTTP puede seguir igual.
Cómo funciona por dentro
Cada petición recorre la misma cadena: aceptar la conexión, parsear HTTP, parsear el JSON, validar el campo esperado, convertir a std::vector<double>, multiplicar por los pesos, aplicar la sigmoide, serializar y escribir en el socket. Con cuatro features el cálculo es ruido: el coste está en el resto.
| Etapa | Orden de magnitud | Qué la domina |
|---|---|---|
| Conexión TCP y HTTP | decenas de µs | Syscalls, latencia de red local |
| Parseo del JSON | 1–5 µs | Tamaño del cuerpo, asignaciones |
| Validación y conversión | < 1 µs | Comprobaciones y copia a vector |
| Producto escalar y sigmoide | decenas de ns | Caché L1, una exponencial |
| Serialización de salida | < 1 µs | Formateo de un double |
La concurrencia decide cuántas cadenas avanzan a la vez. cpp-httplib usa un pool de hilos: cada conexión se atiende en un hilo del pool, que por defecto mide max(8, núcleos - 1). Un manejador que duerme bloquea su hilo, y con keep-alive una conexión inactiva también lo ocupa. Por eso el tamaño del pool y la concurrencia del cliente deben conversar.
El modelo cargado nunca cambia: es de solo lectura y varios hilos pueden llamarlo sin candados. Lo único mutable es el contador de peticiones, que basta con un std::atomic. La recarga en caliente rompe esa inmutabilidad y exige sincronización explícita.
Estructura y dependencias
servidor/
├── CMakeLists.txt
├── Dockerfile
├── include/
│ ├── httplib.h # biblioteca HTTP (una sola cabecera)
│ ├── nlohmann/
│ │ └── json.hpp # JSON (una sola cabecera)
│ └── model.hpp
├── models/
│ └── logistic.json # pesos y metadatos del modelo
├── src/
│ ├── model.cpp # carga y prediccion
│ ├── predecir.cpp # cliente de consola, sin red
│ └── main.cpp # servidor HTTP
└── payload.json # cuerpo de prueba para abAmbas dependencias son de cabecera: cpp-httplib para el servidor y nlohmann/json para el parseo. Puedes dejarlas dentro del repositorio, de modo que compilar no necesite red y la versión quede fijada. FetchContent en CMake las descarga al configurar: cómodo, pero cada build depende de la red.
mkdir -p include/nlohmann
curl -L -o include/httplib.h \
https://raw.githubusercontent.com/yhirose/cpp-httplib/v0.16.3/httplib.h
curl -L -o include/nlohmann/json.hpp \
https://raw.githubusercontent.com/nlohmann/json/v3.11.3/single_include/nlohmann/json.hppSintaxis: el modelo como contrato de datos
{
"version": "1.0.0",
"features": ["edad", "ingreso_mensual", "visitas", "deuda"],
"bias": -1.2,
"weights": [0.03, 0.00012, 0.4, -0.002]
}El archivo dice todo lo necesario: versión, orden exacto de las features, sesgo y pesos. Ese orden es parte del contrato; si el cliente envía los valores en otro, la predicción es silenciosamente incorrecta. De ahí que cada respuesta incluya la versión del modelo y que convenga validar el esquema al arrancar.
#pragma once
#include <cstddef>
#include <string>
#include <vector>
struct Prediccion {
double probabilidad;
int etiqueta;
};
// Regresion logistica con pesos cargados desde JSON.
// Es inmutable tras cargar: se puede usar desde varios hilos sin sincronizar.
class ModeloLogistico {
public:
static ModeloLogistico cargar(const std::string& ruta);
std::size_t num_features() const noexcept { return pesos_.size(); }
const std::string& version() const noexcept { return version_; }
const std::vector<std::string>& features() const noexcept { return features_; }
Prediccion predecir(const std::vector<double>& x) const;
private:
std::string version_;
std::vector<std::string> features_;
std::vector<double> pesos_;
double sesgo_ = 0.0;
};La clase no expone setters: se construye con cargar y solo se consulta. Esa inmutabilidad hace segura la concurrencia. cargar es estática porque un objeto a medio construir no debería existir: o el archivo es válido y devuelve un modelo completo, o lanza una excepción.
Ejemplo básico: cargar pesos y predecir sin red
#include "model.hpp"
#include <cmath>
#include <fstream>
#include <nlohmann/json.hpp>
#include <stdexcept>
ModeloLogistico ModeloLogistico::cargar(const std::string& ruta) {
std::ifstream entrada(ruta);
if (!entrada) {
throw std::runtime_error("no se pudo abrir el modelo: " + ruta);
}
nlohmann::json documento;
try {
documento = nlohmann::json::parse(entrada);
} catch (const nlohmann::json::parse_error& error) {
throw std::runtime_error(std::string("JSON invalido en el modelo: ") + error.what());
}
ModeloLogistico modelo;
try {
modelo.version_ = documento.at("version").get<std::string>();
modelo.features_ = documento.at("features").get<std::vector<std::string>>();
modelo.pesos_ = documento.at("weights").get<std::vector<double>>();
modelo.sesgo_ = documento.at("bias").get<double>();
} catch (const nlohmann::json::exception& error) {
throw std::runtime_error(std::string("modelo incompleto: ") + error.what());
}
if (modelo.pesos_.size() != modelo.features_.size()) {
throw std::runtime_error("el modelo tiene " + std::to_string(modelo.pesos_.size()) +
" pesos y " + std::to_string(modelo.features_.size()) + " features");
}
return modelo;
}
Prediccion ModeloLogistico::predecir(const std::vector<double>& x) const {
if (x.size() != pesos_.size()) {
throw std::invalid_argument("se esperaban " + std::to_string(pesos_.size()) +
" features y llegaron " + std::to_string(x.size()));
}
double z = sesgo_;
for (std::size_t i = 0; i < pesos_.size(); ++i) {
z += pesos_[i] * x[i];
}
const double probabilidad = 1.0 / (1.0 + std::exp(-z));
return {probabilidad, probabilidad >= 0.5 ? 1 : 0};
}#include <exception>
#include <iostream>
#include <vector>
#include "model.hpp"
int main() {
try {
const ModeloLogistico modelo = ModeloLogistico::cargar("models/logistic.json");
const std::vector<double> x{34.0, 2500.0, 4.0, 1200.0};
const Prediccion p = modelo.predecir(x);
std::cout << "modelo " << modelo.version() << " con " << modelo.num_features() << " features\n";
std::cout << "probabilidad = " << p.probabilidad << "\n";
std::cout << "etiqueta = " << p.etiqueta << "\n";
} catch (const std::exception& error) {
std::cerr << "error: " << error.what() << "\n";
return 1;
}
return 0;
}g++ -O2 -std=c++20 -Wall -Wextra -Iinclude \
src/model.cpp src/predecir.cpp -o predecir
./predecir
# modelo 1.0.0 con 4 features
# probabilidad = 0.336261
# etiqueta = 0Comprueba la cuenta: z = -1.2 + 0.03·34 + 0.00012·2500 + 0.4·4 − 0.002·1200 = −0.68, y 1 / (1 + e^0.68) = 0.336. Si no coincide, el problema está en el orden de los pesos o en una feature mal escalada. Cambia la edad a 52 y la probabilidad salta por encima de 0.99: los pesos están pensados para esas magnitudes.
Separar la inferencia de la red tiene una ventaja práctica: este programa se depura sin clientes y sirve de prueba de humo del modelo. Cuando algo falla en producción, ejecutarlo con el mismo logistic.json descarta medio problema.
Ejemplo intermedio: el servidor HTTP
#include <atomic>
#include <cstdlib>
#include <exception>
#include <iostream>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include "httplib.h"
#include "model.hpp"
namespace {
using nlohmann::json;
std::string variable_entorno(const char* nombre, const std::string& por_defecto) {
const char* valor = std::getenv(nombre);
return valor != nullptr ? std::string(valor) : por_defecto;
}
void responder_error(httplib::Response& res, int codigo, const std::string& mensaje) {
res.status = codigo;
res.set_content(json{{"error", mensaje}}.dump(), "application/json");
}
} // namespace
int main() {
const std::string ruta_modelo = variable_entorno("MODELO_PATH", "models/logistic.json");
const int puerto = std::stoi(variable_entorno("PORT", "8080"));
ModeloLogistico modelo;
try {
modelo = ModeloLogistico::cargar(ruta_modelo);
} catch (const std::exception& error) {
std::cerr << "no se pudo cargar el modelo: " << error.what() << "\n";
return 1;
}
std::atomic<long> peticiones{0};
httplib::Server servidor;
servidor.set_payload_max_length(64 * 1024);
servidor.set_read_timeout(5, 0);
servidor.set_write_timeout(5, 0);
servidor.Get("/health", [&modelo](const httplib::Request&, httplib::Response& res) {
res.set_content(json{{"estado", "ok"},
{"modelo", modelo.version()},
{"features", modelo.num_features()}}
.dump(),
"application/json");
});
servidor.Get("/stats", [&peticiones](const httplib::Request&, httplib::Response& res) {
res.set_content(json{{"peticiones", peticiones.load()}}.dump(), "application/json");
});
servidor.Post("/predict",
[&modelo, &peticiones](const httplib::Request& req, httplib::Response& res) {
++peticiones;
json cuerpo;
try {
cuerpo = json::parse(req.body);
} catch (const json::parse_error&) {
responder_error(res, 400, "el cuerpo no es JSON valido");
return;
}
if (!cuerpo.contains("features") || !cuerpo["features"].is_array()) {
responder_error(res, 400, "falta el array 'features'");
return;
}
std::vector<double> x;
try {
x = cuerpo["features"].get<std::vector<double>>();
} catch (const json::type_error&) {
responder_error(res, 400, "'features' debe contener solo numeros");
return;
}
try {
const Prediccion p = modelo.predecir(x);
res.set_content(json{{"probabilidad", p.probabilidad},
{"etiqueta", p.etiqueta},
{"modelo", modelo.version()}}
.dump(),
"application/json");
} catch (const std::exception& error) {
responder_error(res, 400, error.what());
}
});
std::cout << "escuchando en 0.0.0.0:" << puerto << " con modelo " << modelo.version() << "\n";
if (!servidor.listen("0.0.0.0", puerto)) {
std::cerr << "no se pudo abrir el puerto " << puerto << "\n";
return 1;
}
return 0;
}g++ -O2 -std=c++20 -Wall -Wextra -Iinclude -pthread \
-DCPPHTTPLIB_THREAD_POOL_COUNT=64 \
-DCPPHTTPLIB_LISTEN_BACKLOG=128 \
src/model.cpp src/main.cpp -o servidor
./servidor &
# escuchando en 0.0.0.0:8080 con modelo 1.0.0
curl -s localhost:8080/health
# {"estado":"ok","features":4,"modelo":"1.0.0"}
curl -s -X POST localhost:8080/predict \
-H 'Content-Type: application/json' \
-d '{"features":[34,2500,4,1200]}'
# {"etiqueta":0,"modelo":"1.0.0","probabilidad":0.33626130259564735}
curl -s -X POST localhost:8080/predict -d '{"x":1}'
# {"error":"falta el array 'features'"}
curl -s -X POST localhost:8080/predict -d '{"features":[1,2,3]}'
# {"error":"se esperaban 4 features y llegaron 3"}Cada rechazo devuelve 400 con un mensaje que dice qué falta, y ningún error se convierte en un 200 con un campo error dentro: el código de estado es el contrato que entienden clientes y balanceadores. El 500 se reserva para fallos del servidor; aquí casi todos los fallos posibles son del cliente, porque el modelo ya se validó al arrancar.
Fíjate en las dos definiciones de compilación. CPPHTTPLIB_LISTEN_BACKLOG fija cuántas conexiones esperan en la cola del sistema; su valor por defecto es 5, y con 50 clientes eso produce rechazos. CPPHTTPLIB_THREAD_POOL_COUNT fija el pool, por defecto max(8, núcleos - 1).
Visualización: de features a probabilidad
La predicción es una suma ponderada. Recorre las celdas: cada una es el peso multiplicado por su feature, y el sesgo se suma aparte. Si un peso tiene el signo cambiado, la contribución cambia de lado y la probabilidad se mueve en la dirección contraria.
Recorre los pasos para ver cómo cambian las celdas.
Visualización: una petición paso a paso
Ahora el mismo cálculo dentro del manejador. Observa que el cuerpo llega como texto, se convierte en un objeto JSON, después en un vector de double y solo entonces entra en el modelo. Cada conversión puede fallar, y cada fallo tiene su respuesta.
servidor.Post("/predict", [&](const httplib::Request& req, httplib::Response& res) { json cuerpo = json::parse(req.body); std::vector<double> x = cuerpo["features"].get<std::vector<double>>(); Prediccion p = modelo.predecir(x); res.set_content(json{{"probabilidad", p.probabilidad}}.dump(), "application/json");});Content-Type: application/json y 40 bytes de cuerpo. httplib ya parseó la línea de petición y las cabeceras.—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
Caso práctico: concurrencia, carga y Docker
La pregunta correcta no es «¿cuántas peticiones por segundo hace?» sino «¿qué pasa con el percentil 99 cuando hay 50 clientes?». Con el pool por defecto y keep-alive, las conexiones inactivas ocupan hilos y las peticiones esperan hasta agotar el tiempo de lectura. Es un efecto medible que se arregla con una línea.
| Configuración del servidor | Peticiones/s | p99 |
|---|---|---|
Pool por defecto max(8, núcleos-1), backlog 128 | 199 | 5012 ms |
THREAD_POOL_COUNT=64, backlog 128 | 41424 | 7 ms |
printf '{"features":[34,2500,4,1200]}' > payload.json
ab -n 3000 -c 50 -k -p payload.json -T application/json \
http://127.0.0.1:8080/predict
# Complete requests: 3000
# Failed requests: 0
# Requests per second: 41424.45 [#/sec] (mean)
# Time per request: 1.207 [ms] (mean)
# 50% 1
# 99% 7La tabla se midió en un Apple Silicon con ab -n 3000 -c 50 -k; tus cifras cambiarán, pero el salto se repite siempre que el pool sea menor que la concurrencia y el keep-alive esté activo. La lección: el throughput de un servidor bloqueante lo acota el número de hilos, no la velocidad del modelo. ab es monohilo y puede convertirse en el cuello; para cifras serias usa wrk o hey y vigila la CPU del cliente.
Docker cierra el círculo con dos etapas: la primera instala compilador y CMake y produce el binario; la segunda copia solo el ejecutable y sus librerías. El compilador no viaja a producción y la imagen final baja de cientos a decenas de megabytes.
# ---- etapa 1: compilacion ----
FROM debian:bookworm-slim AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends g++ cmake make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY include/ include/
COPY src/ src/
COPY models/ models/
COPY CMakeLists.txt .
RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
&& cmake --build build -j"$(nproc)"
# ---- etapa 2: imagen final, sin compilador ----
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends libstdc++6 curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/build/servidor /usr/local/bin/servidor
COPY models/ models/
ENV MODELO_PATH=/app/models/logistic.json
ENV PORT=8080
EXPOSE 8080
USER nobody
ENTRYPOINT ["/usr/local/bin/servidor"]docker build -t inferencia:0.1 .
docker run --rm -d --name inferencia -p 8080:8080 inferencia:0.1
curl -s localhost:8080/health
# {"estado":"ok","features":4,"modelo":"1.0.0"}
docker logs inferencia
# escuchando en 0.0.0.0:8080 con modelo 1.0.0
docker stop inferenciaErrores comunes
res.set_content(json{{"error", "faltan features"}}.dump(), "application/json");
// el cliente recibe 200 y tiene que adivinar si hubo exitores.status = 400;
res.set_content(json{{"error", "falta el array 'features'"}}.dump(), "application/json");Un cliente que solo mira el cuerpo no puede distinguir éxito de error, y un balanceador tampoco puede contar fallos. Los códigos 4xx indican que la petición está mal; los 5xx, que el servidor falló. Mezclarlos rompe reintentos, alertas y paneles de disponibilidad.
- Deserializar sin comprobar el campo:
cuerpo["features"]sobre un JSON sin esa clave lanzatype_errordentro del manejador y produce un 500 en lugar de un 400 explicativo. - Cambiar el orden de las features: el modelo no lo detecta. Fija el orden en el JSON, expón los nombres en
/healthy valida la longitud; un desajuste de semántica es el error más caro de diagnosticar. - Suponer que el modelo cabe en cualquier formato:
pickleata el archivo a una versión de Python. JSON o un binario con cabecera son portables; elige uno y documenta el esquema. - Recargar el modelo sin candado: asignar
modelo_ = nuevomientras otro hilo predice es una carrera. Carga fuera del candado y sustituye dentro de ununique_lock. - Ignorar el pool y el backlog: con los valores por defecto, 50 clientes concurrentes producen conexiones rechazadas y esperas de segundos. Ajusta ambos o limita la concurrencia en el proxy.
- Registrar el cuerpo completo: las features suelen ser datos personales. Registra estado, latencia y versión, no los valores.
Buenas prácticas
- Valida el modelo al arrancar y falla rápido: un servicio que arranca sin modelo es peor que uno que no arranca.
- Devuelve la versión del modelo en cada respuesta; sin ella no puedes correlacionar un cambio de comportamiento con un despliegue.
- Mantén el modelo inmutable después de cargarlo y protege cualquier recarga con un candado de lectura/escritura.
- Fija las versiones de las dependencias y guárdalas en el repositorio; una cabecera que cambia sola es un incidente esperando.
- Mide latencia por percentiles, no por media: la media esconde exactamente la cola que sufren los usuarios.
- Usa una imagen de dos etapas y ejecuta como usuario sin privilegios; el compilador no tiene nada que hacer en producción.
- Documenta el contrato con un ejemplo de petición y otro de error; los clientes se escriben a partir de ejemplos.
import json
import math
with open("models/logistic.json", encoding="utf-8") as f:
modelo = json.load(f)
x = [34, 2500, 4, 1200]
z = modelo["bias"] + sum(w * v for w, v in zip(modelo["weights"], x))
print(1 / (1 + math.exp(-z))) # 0.33626130259564735double z = sesgo_;
for (std::size_t i = 0; i < pesos_.size(); ++i) z += pesos_[i] * x[i];
const double p = 1.0 / (1.0 + std::exp(-z));
return {p, p >= 0.5 ? 1 : 0};El resultado debe ser idéntico bit a bit o casi: ambos usan `double` de 64 bits y la misma exponencial de la libm. Si difieren en el séptimo decimal, suele ser por el orden de la suma. La diferencia real no está en la aritmética sino en el contexto: Python carga el JSON en cada ejecución, C++ lo hace una vez y sirve miles de peticiones.
Ejercicios
Predicción por lotes
predecir_lote para aplicar el modelo a cada fila de un lote, reservando el vector de salida. Pruébalo con [[34, 2500, 4, 1200], [52, 7000, 15, 300]] y comprueba que las etiquetas son 0 y 1. Después conecta un endpoint /predict/batch que reciba {"lote": [[...], [...]]}.Pista
reserve evita reallocaciones. Para el endpoint, valida que lote sea un array de arrays y reutiliza el mismo manejo de errores que ya tienes.
Recarga en caliente sin carreras
ModeloServible con un std::shared_mutex: predecir toma el candado compartido y recargar el exclusivo, cargando el archivo nuevo fuera del candado. Comprueba que recargar y predecir intercalados dan siempre el mismo valor.Pista
std::shared_lock para lectores, std::unique_lock para el escritor. Cargar dentro del candado bloquearía todas las predicciones durante la lectura de disco.
#include <cmath> double probabilidad(double z) { return 1.0 / (1.0 + std::()); }
La sigmoide es 1 / (1 + e^(-z)). La función de C++ para e^x es std::exp.
Soluciones explicadas
El lote y la petición individual son el mismo cálculo con distinto empaquetado. El coste fijo por petición —conexión, cabeceras, parseo— se paga una vez, y por eso los servicios reales aceptan lotes: un modelo pequeño servido de uno en uno gasta casi todo el tiempo en burocracia. La contrapartida es la latencia: agrupar introduce una espera artificial.
La recarga en caliente es un problema de concurrencia clásico: dos hilos, un recurso compartido, un lector lento y un escritor. El candado funciona porque las lecturas son frecuentes y la escritura rara; si la recarga fuera constante, publicar un std::shared_ptr<const ModeloLogistico> con carga atómica sería mejor, porque cada petición copiaría el puntero sin bloquear a nadie.
Resumen
- Un servidor de inferencia es una función pura expuesta por red: recibe features, devuelve predicción, no guarda estado.
- Separa carga, validación e inferencia; cada etapa tiene su propio modo de fallo y su propio mensaje.
- Carga el modelo al arrancar y falla rápido; expón su versión en cada respuesta.
- Los códigos de estado son el contrato: 400 para entradas inválidas, 500 para fallos del servidor.
cpp-httplibusa un pool de hilos; su tamaño y el backlog del socket limitan el throughput real.- Un modelo inmutable se comparte sin candados; la recarga en caliente exige
shared_mutexo punteros atómicos. - Mide p50 y p99 bajo carga; la media oculta la cola que sufre el usuario.
- Docker en dos etapas deja el compilador fuera de producción y fija el entorno de ejecución.
Evaluación
El cliente envía {"features": [34, 2500]} a un modelo de 4 features. ¿Qué debe responder el servidor?
¿Por qué el modelo se declara inmutable después de cargarlo?
Con el pool por defecto, 50 clientes con keep-alive provocan esperas de segundos. ¿Cuál es la causa más probable?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.