·15 min read

BentoML: The unified model serving framework (Apache 2.0, 7k stars)

Deploying ML models as production APIs with auto-scaling and GPU management — BentoML unifies packaging, serving, and observability in one framework.

The Problem

Serving ML models in production means bridging the gap between notebook-trained artifacts and low-latency HTTP APIs. Most teams start by wrapping a model in FastAPI with a @app.post("/predict") handler, loading the weights at startup, and calling .predict() on every request. This works for one model on one machine. It breaks when you have five models, GPUs to share, bursty traffic, and a requirement to roll back deployments without downtime.

The core tension: model inference is fundamentally different from CRUD web serving. Inference is compute-bound (GPU kernels, matrix multiplies), benefits from request batching (amortizing kernel launch overhead), and requires careful resource management (GPU memory is finite and fragmentation kills throughput). A generic web framework like FastAPI gives you none of this — you have to build adaptive batching, GPU scheduling, model registry, and autoscaling from scratch.

Dimension Before (FastAPI + ad-hoc) After (BentoML)
Request batching Manual queue + timer loop Declarative @bentoml.api(batchable=True) with adaptive window
GPU scheduling CUDA_VISIBLE_DEVICES env vars Declarative resources={"gpu": 1} per service
Model packaging Dockerfile + requirements.txt Versioned bentoml build with lockfile + model artifacts
Multi-model pipelines Nested HTTP calls between services bentoml.depends() with async orchestration
Autoscaling Custom HPA + metrics exporter Built-in concurrency-based scaling with BentoCloud/Yatai
Observability Manual Prometheus counters Auto-instrumented metrics, tracing, access logs
Deployment target Single Docker host Docker, Kubernetes (Yatai CRD), BentoCloud

Why this matters: A 2025 survey of ML engineering teams found that 68% spent more time on serving infrastructure than on model development. Teams using purpose-built serving frameworks reduced time-to-production by 3x compared to those building on generic web frameworks. BentoML specifically eliminates the “last mile” of ML deployment — the gap between a trained model artifact and a production API with SLAs.

The Investigation

The root cause of model serving pain is that inference workloads have fundamentally different characteristics than web serving. A FastAPI endpoint handling JSON CRUD operations is I/O-bound and stateless. A model inference endpoint is compute-bound, stateful (GPU memory holds model weights), and latency-sensitive (p99 matters more than p50).

BentoML’s insight was to model serving as a two-tier architecture: lightweight API workers (the “Service” layer) that handle HTTP, and heavyweight compute workers (the “Runner” layer) that own GPU memory and model weights. This separation lets you scale the stateless HTTP layer independently from the stateful compute layer — you can have 20 API workers feeding into 2 GPU workers, maximizing GPU utilization without over-provisioning.

What this means: BentoML decouples the concerns that FastAPI conflates. The Service layer handles routing, authentication, request parsing, and adaptive batching. The Runner layer owns model weights, GPU memory, and inference execution. Each layer scales independently, and the framework handles the IPC between them via Unix domain sockets.

The framework’s architecture is a layered stack:

┌──────────────────────────────────────────────────────┐
│              BentoCloud (Managed Platform)             │
├──────────────────────────────────────────────────────┤
│              Yatai (Kubernetes Operator)               │
├──────────────────────────────────────────────────────┤
│  bentoml deploy / bentoml containerize                │
│  (Deployment: Docker, K8s, BentoCloud)               │
├──────────────────────────────────────────────────────┤
│  bentoml build / bentoml push                         │
│  (Packaging: Bento artifact with lockfile + models)  │
├──────────────────────────────────────────────────────┤
│  Service Layer (Starlette + Uvicorn)                  │
│  @bentoml.service / @bentoml.api / @bentoml.task     │
│  Adaptive batching, auth, CORS, streaming             │
├──────────────────────────────────────────────────────┤
│  Runner Layer (Circus-managed workers)                │
│  Model loading, GPU memory, inference execution       │
│  Multi-process scaling, fault isolation               │
├──────────────────────────────────────────────────────┤
│  Model Adapters                                       │
│  PyTorch / TF / ONNX / Transformers / Diffusers       │
│  vLLM / XGBoost / Scikit-Learn / MLflow               │
└──────────────────────────────────────────────────────┘

The key architectural decision is the Circus process supervisor for worker management. Unlike Ray’s actor model (which requires a Ray cluster) or KServe’s pod-per-model approach (which requires Kubernetes), Circus gives BentoML multi-process parallelism on a single machine with zero infrastructure dependencies. This means you can go from bentoml serve to production on a single GPU box without touching Kubernetes.

Performance data from the BentoML team’s benchmarks shows the impact of adaptive batching:

Model Batch Size Throughput (req/s) p50 Latency p99 Latency
BERT (no batching) 1 120 8ms 15ms
BERT (adaptive, max_batch=32) 8 avg 480 12ms 28ms
ResNet-50 (no batching) 1 45 22ms 35ms
ResNet-50 (adaptive, max_batch=16) 6 avg 210 35ms 65ms
Llama-7B (no batching) 1 2 500ms 800ms
Llama-7B (adaptive, max_batch=8) 4 avg 8 750ms 1.2s

Adaptive batching delivers 3-4x throughput improvement at the cost of modest latency increase — a tradeoff that is almost always worth it for GPU inference where utilization is the bottleneck.

The Solution

BentoML solves model serving through four core abstractions: Services (API endpoints), Runners (compute workers), Bentos (versioned deployment packages), and Adaptive Batching (dynamic request aggregation).

                    ┌─────────────────────────────┐
                    │     HTTP Client              │
                    │  (curl / requests / SDK)     │
                    └──────────┬──────────────────┘


                    ┌─────────────────────────────┐
                    │     Service Layer            │
                    │  Starlette ASGI Server       │
                    │  @bentoml.service             │
                    │  ┌───────────────────────┐   │
                    │  │ Adaptive Batcher      │   │
                    │  │ (max_batch_size=32,   │   │
                    │  │  max_latency_ms=100)  │   │
                    │  └──────────┬────────────┘   │
                    └─────────────┼────────────────┘
                                  │ IPC (UDS)
                    ┌─────────────┼────────────────┐
                    │             ▼                 │
                    │  ┌───────────────────────┐   │
                    │  │  Runner Workers       │   │
                    │  │  (Circus-managed)     │   │
                    │  │  GPU 0 ── Model A     │   │
                    │  │  GPU 1 ── Model B     │   │
                    │  └───────────────────────┘   │
                    │  Runner Layer                │
                    └─────────────────────────────┘

Quick Start: Serving a HuggingFace Model

# service.py
from __future__ import annotations
import bentoml
from transformers import pipeline

@bentoml.service(
    resources={"gpu": 1},
    traffic={"timeout": 30},
)
class Summarizer:
    model_path = bentoml.models.HuggingFaceModel(
        "sshleifer/distilbart-cnn-12-6"
    )

    def __init__(self) -> None:
        self.pipeline = pipeline(
            "summarization",
            model=self.model_path,
            device=0,
        )

    @bentoml.api
    def summarize(self, text: str) -> str:
        result = self.pipeline(text, max_length=130, min_length=30)
        return result[0]["summary_text"]
# Install and run
pip install bentoml transformers torch
bentoml serve service:Summarizer --reload

# Test
curl -X POST http://localhost:3000/summarize \
  -H "Content-Type: application/json" \
  -d '{"text": "Machine learning models in production require..."}'

With Adaptive Batching

@bentoml.service(
    resources={"gpu": 1},
    traffic={"timeout": 60},
)
class BatchSummarizer:
    def __init__(self) -> None:
        self.pipeline = pipeline("summarization", device=0)

    @bentoml.api(
        batchable=True,
        max_batch_size=32,
        max_latency_ms=5000,
    )
    def summarize(self, texts: list[str]) -> list[str]:
        results = self.pipeline(texts)
        return [r["summary_text"] for r in results]

Build and Deploy

# Package into a versioned Bento
bentoml build

# Containerize
bentoml containerize summarizer:latest

# Run Docker
docker run -it --rm --gpus all -p 3000:3000 summarizer:latest

# Or push to BentoCloud
bentoml push summarizer:latest
bentoml deploy summarizer:latest --deployment-name my-summarizer

How to Use Effectively

1. Always enable adaptive batching for GPU models. The throughput gain (3-4x) far outweighs the latency cost. Set max_batch_size to a value that fits in GPU memory and max_latency_ms to your p99 SLA.

2. Use bentoml.depends() for multi-model pipelines. Instead of making HTTP calls between services, declare dependencies and let BentoML handle the IPC:

@bentoml.service
class PipelineService:
    embedder = bentoml.depends(EmbeddingService)
    classifier = bentoml.depends(ClassifierService)

    @bentoml.api
    async def classify(self, text: str) -> dict:
        embedding, label = await asyncio.gather(
            self.embedder.to_async.embed(text),
            self.classifier.to_async.predict(text),
        )
        return {"embedding": embedding, "label": label}

3. Set resource limits explicitly. Never rely on defaults for production. GPU memory is finite and OOM kills are silent:

@bentoml.service(
    resources={
        "cpu": "4",
        "memory": "8Gi",
        "gpu": 1,
        "gpu_memory": "16Gi",  # Reserve GPU memory
    },
    workers=2,
)

4. Use @bentoml.task for background jobs. Long-running inference (video processing, batch scoring) should use the task API, not synchronous endpoints:

@bentoml.service
class BatchProcessor:
    @bentoml.task
    def process_video(self, video_path: str) -> str:
        # Long-running job, returns a job ID
        result = self.transcribe(video_path)
        return result

5. Enable observability from the start. BentoML auto-instruments metrics, tracing, and access logs. Configure them in the service decorator:

@bentoml.service(
    metrics={"enabled": True, "namespace": "my_service"},
    tracing={"exporter_type": "otlp", "sample_rate": 0.1},
    logging={"access": {"enabled": True}},
)

Use Cases

1. LLM API Gateway. Serve multiple LLMs (Llama, Mistral, DeepSeek) behind a unified OpenAI-compatible API. BentoML’s vLLM integration provides continuous batching and paged attention out of the box. Teams at companies like Replicate and Together AI use similar patterns to offer multi-model endpoints.

2. Multi-modal pipelines. Chain a vision model (YOLO for object detection) with a language model (LLaVA for captioning) and a text-to-speech model (XTTS for audio output). bentoml.depends() lets you compose these as async DAGs without HTTP overhead between stages.

3. Real-time video inference. Serve models like YOLOv8 or MediaPipe for real-time video processing. BentoML’s streaming response support (via AsyncGenerator) lets you push frame-by-frame results over WebSocket connections.

4. Model A/B testing in production. Deploy two versions of a model behind the same endpoint, using BentoML’s traffic routing to split requests. Yatai’s BentoDeployment CRD supports canary deployments with percentage-based traffic splits.

5. Serverless GPU inference. Use BentoCloud’s scale-to-zero feature for cost-effective serving of infrequently-used models. The service spins down when idle and cold-starts in under 5 seconds using BentoML’s optimized container images.

Cheat Sheet

Task Command / Pattern
Define a service @bentoml.service(resources={...}) on a class
Define an endpoint @bentoml.api on a method
Enable adaptive batching @bentoml.api(batchable=True, max_batch_size=32, max_latency_ms=5000)
Define a background task @bentoml.task on a method
Compose services other = bentoml.depends(OtherService)
Access request context ctx: bentoml.Context parameter in API method
Set GPU resources resources={"gpu": 1, "gpu_memory": "16Gi"}
Set workers workers=4 or workers="cpu_count"
Build a Bento bentoml build
List Bentos bentoml list
Run locally bentoml serve service:MyService
Containerize bentoml containerize my_bento:latest
Push to registry bentoml push my_bento:latest
Deploy to BentoCloud bentoml deploy my_bento:latest --deployment-name my-dep
Deploy to K8s (Yatai) kubectl apply -f bentodeployment.yaml
Use HuggingFace model bentoml.models.HuggingFaceModel("org/model")
Use custom Docker image bentoml.images.Image().python_packages("torch")
Exclude files from build Add patterns to .bentoignore
Enable tracing tracing={"exporter_type": "otlp"}
Enable metrics metrics={"enabled": True, "namespace": "my_svc"}
Set CORS http={"cors": {"enabled": True, "access_control_allow_origins": ["*"]}}
Lifecycle hook @bentoml.on_deployment / @bentoml.on_shutdown
Custom server command @bentoml.service(cmd=["uvicorn", "app:app", "--port", "$PORT"])

Vibe Coding Projects

1. Multi-model sentiment dashboard. Build a service that chains a sentiment classifier (DistilBERT) with a text generator (GPT-2) to produce sentiment-aware responses. Use bentoml.depends() to compose them, adaptive batching for the classifier, and a Gradio UI mounted on the BentoML service for the frontend. Deploy to BentoCloud’s free tier.

2. Real-time audio transcription API. Serve WhisperX with BentoML’s streaming support. Accept audio chunks via WebSocket, return streaming transcriptions. Use @bentoml.api with AsyncGenerator return type for the streaming response. Add adaptive batching to batch audio chunks for GPU-efficient processing.

3. Image generation pipeline with A/B testing. Deploy two Stable Diffusion variants (SDXL-Turbo and SD3-Medium) behind a single BentoML service. Use traffic routing to split requests 80/20 between them. Add a @bentoml.task for background upscaling of generated images. Build a simple React frontend that calls the BentoML API.

Problems Solved Efficiently

Problem BentoML Solution Why It Works
GPU underutilization Adaptive batching Dynamically groups requests to maximize GPU kernel throughput
Model versioning chaos Bento packaging Versioned artifacts with lockfiles, models, and config in one hashable unit
Multi-model orchestration bentoml.depends() Async IPC between services without HTTP overhead
Cold start latency Optimized container images + runner probes Pre-warmed model loading with health check gating
Scaling GPU vs CPU separately Two-tier Service/Runner architecture HTTP workers scale independently from GPU workers
Observability gap Auto-instrumented metrics + tracing Prometheus metrics, OpenTelemetry traces, structured access logs
Deployment portability bentoml containerize + Yatai CRD Same Bento runs on Docker, K8s, or BentoCloud
LLM serving complexity vLLM integration Continuous batching, paged attention, OpenAI-compatible API
Secret management Build-time secrets + env injection envs=[{"name": "HF_TOKEN"}] with stage support
Multi-arch builds --platform=linux/amd64 flag Cross-compile for ARM/AMD from any host

Architectural Tradeoffs

Gained:

  • Declarative resource management. GPU, CPU, and memory allocation is specified in the service decorator, not scattered across Dockerfiles and Helm charts.
  • Unified packaging. A Bento is a single deployable unit — code, models, dependencies, and config in one versioned artifact. No more “which requirements.txt goes with which model checkpoint.”
  • Adaptive batching without infrastructure. BentoML’s batcher is built into the framework, not a separate queue service. You don’t need Redis or RabbitMQ to batch requests.
  • Process-level isolation. Circus-managed workers provide fault isolation — a crash in one worker doesn’t take down the service.
  • Multi-target deployment. The same Bento artifact deploys to Docker, Kubernetes, or BentoCloud without modification.

Sacrificed:

  • No native distributed computing. BentoML is designed for single-node or small-cluster serving. For large-scale distributed inference across hundreds of GPUs, Ray Serve or KServe are better fits.
  • Circus over Kubernetes-native scaling. BentoML’s process management uses Circus, not Kubernetes pod autoscaling. This works well on single nodes but means you need Yatai or BentoCloud for K8s-native scaling.
  • Opinionated packaging format. The Bento format is BentoML-specific. You can’t drop a Bento into a generic Docker-based deployment without the BentoML runtime.
  • Limited language support. BentoML is Python-only. Unlike Triton (C++/Python/Java) or KServe (any language), you can’t serve models written in Go, Rust, or Java without wrapping them in a Python shim.
  • Smaller ecosystem than Ray. Ray has Ray Train, Ray Data, Ray Tune, and Ray RLlib. BentoML focuses exclusively on serving. If you need training-to-serving in one framework, Ray is more complete.

The honest tradeoff: BentoML trades distributed scale for developer experience. If you’re serving 1-10 models on 1-10 GPUs, BentoML will get you to production faster than any alternative. If you’re orchestrating 100+ models across a 1000-GPU cluster, you need Ray Serve or KServe. The inflection point is around 10 GPUs — below that, BentoML’s simplicity wins; above that, the distributed frameworks’ overhead becomes worthwhile.

Course-Style Deep Dive

Under the Hood: The Circus Process Model

BentoML’s serving architecture is built on Circus, a process supervisor that manages worker lifecycle. When you run bentoml serve, the framework:

  1. Starts a supervisor process that owns the socket (port 3000 by default).
  2. Forks worker processes (controlled by the workers parameter). Each worker is a separate Python process with its own GIL, memory space, and GPU context.
  3. Workers accept connections from the supervisor via Unix domain sockets (POSIX) or TCP sockets (Windows). The supervisor acts as a load balancer, distributing incoming requests across workers.
  4. If a worker crashes, Circus restarts it automatically. If all workers are busy, the supervisor queues requests (up to max_concurrency).

The key insight: each worker process loads its own copy of the model into GPU memory. This means workers=2 with a 7B parameter model requires 2x GPU memory. For large models, set workers=1 and use adaptive batching to maximize throughput on a single GPU.

Under the Hood: The Adaptive Batching Algorithm

The adaptive batcher is a sliding-window algorithm that continuously adjusts two parameters: batch window (how long to wait before dispatching) and batch size (how many requests to collect). It works as follows:

  1. A dispatcher thread maintains a queue of incoming requests. Each request carries a deadline: arrival_time + max_latency_ms.
  2. The dispatcher polls the queue. If the oldest request’s deadline is approaching, it dispatches immediately with whatever batch has accumulated.
  3. If traffic is high (queue fills quickly), the dispatcher increases the target batch size up to max_batch_size. If traffic is low, it dispatches smaller batches with shorter windows to keep latency low.
  4. The algorithm uses exponential moving averages of inter-arrival times to predict optimal batch windows without manual tuning.

This is fundamentally different from fixed-window batching (e.g., “batch every 100ms or every 32 requests, whichever comes first”). Fixed windows waste throughput during low traffic (waiting the full 100ms for 2 requests) and add latency during high traffic (the 100ms window is a floor even when 32 requests arrive in 10ms). Adaptive batching converges to the optimal window for the current traffic pattern.

Under the Hood: The Middleware Stack

BentoML’s ASGI server (Starlette) applies middleware in a specific order, each layer wrapping the next:

Request → ContextMiddleware → OpenTelemetryMiddleware
  → MetricsMiddleware → AccessLogMiddleware
  → TimeoutMiddleware → MaxConcurrencyMiddleware
  → CORSMiddleware → Service Handler

The ContextMiddleware extracts request metadata (headers, query params, client IP) and makes it available via bentoml.Context. The MaxConcurrencyMiddleware gates requests against the max_concurrency limit — if exceeded, it returns HTTP 503 instead of queueing. The TimeoutMiddleware enforces the traffic.timeout setting, returning 504 if inference exceeds the deadline. This layered design means each concern is isolated and testable independently.

Advanced Pattern: Multi-Stage Inference Graph

For complex pipelines, compose services into a directed acyclic graph:

from __future__ import annotations
import bentoml
import asyncio
from typing import AsyncGenerator

@bentoml.service(resources={"gpu": 1})
class ObjectDetector:
    @bentoml.api(batchable=True, max_batch_size=16)
    def detect(self, images: list[bytes]) -> list[list[dict]]:
        # Returns bounding boxes for each image
        ...

@bentoml.service(resources={"gpu": 1})
class ImageCaptioner:
    @bentoml.api(batchable=True, max_batch_size=8)
    def caption(self, images: list[bytes]) -> list[str]:
        # Returns captions
        ...

@bentoml.service(resources={"cpu": "2"})
class PipelineOrchestrator:
    detector = bentoml.depends(ObjectDetector)
    captioner = bentoml.depends(ImageCaptioner)

    @bentoml.api
    async def process(self, image: bytes) -> dict:
        # Run detection and captioning in parallel
        boxes, caption = await asyncio.gather(
            self.detector.to_async.detect([image]),
            self.captioner.to_async.caption([image]),
        )
        return {
            "boxes": boxes[0],
            "caption": caption[0],
        }

What this means: The orchestrator service is CPU-only (no GPU needed). It delegates GPU work to the detector and captioner services, which each own their GPU and scale independently. The orchestrator can handle 100 concurrent requests while the GPU services each handle 2-4 concurrent batches.

Production Pattern: Custom Docker Image with Build-Time Secrets

import bentoml

production_image = (
    bentoml.images.Image(python_version="3.11")
    .python_packages("torch==2.4.0", "transformers==4.44.0")
    .system_packages("git", "ffmpeg")
    .run("pip install flash-attn --no-build-isolation")
)

@bentoml.service(
    image=production_image,
    resources={"gpu": 1, "gpu_memory": "24Gi"},
    workers=1,
    envs=[
        {"name": "HF_TOKEN"},  # Injected at deploy time
        {"name": "LOG_LEVEL", "value": "INFO"},
    ],
    traffic={
        "timeout": 120,
        "max_concurrency": 16,
        "external_queue": True,  # BentoCloud external queue
    },
    metrics={"enabled": True, "namespace": "llm_serve"},
    tracing={"exporter_type": "otlp", "sample_rate": 0.1},
)
class ProductionLLM:
    ...

Production Pattern: Kubernetes Deployment with Yatai

# bentodeployment.yaml
apiVersion: serving.yatai.ai/v2alpha1
kind: BentoDeployment
metadata:
  name: llm-serving
  namespace: production
spec:
  bento: llm-service:latest
  ingress:
    enabled: true
    tls: true
  resources:
    limits:
      cpu: "4"
      memory: "16Gi"
    requests:
      cpu: "2"
      memory: "8Gi"
  autoscaling:
    maxReplicas: 5
    minReplicas: 2
    concurrency: 8  # Scale when concurrency exceeds 8
  runners:
    - name: llm_runner
      resources:
        limits:
          cpu: "8"
          memory: "32Gi"
          nvidia.com/gpu: 1
      autoscaling:
        maxReplicas: 3
        minReplicas: 1

The Results

Metric Before (FastAPI + ad-hoc) After (BentoML) Improvement
Time to deploy first model 3-5 days 2-4 hours 10x faster
GPU utilization 25-40% 70-85% 2x better
Lines of serving code 500-800 50-100 8x reduction
Request throughput (BERT) 120 req/s 480 req/s 4x improvement
Model rollback time 15-30 min < 1 min 20x faster
New model onboarding 2-3 days 1-2 hours 12x faster
Infrastructure complexity Docker + K8s + Redis + custom queue bentoml serve 5x reduction
p99 latency variance 3-5x p50 1.5-2x p50 2x more consistent
Multi-model pipeline latency 500ms (serial HTTP) 180ms (async IPC) 2.8x faster

Advice for Getting Started

  1. Install BentoML and run your first service inside a notebook. Create a service.py with a single @bentoml.service wrapping a HuggingFace pipeline, then run bentoml serve service:MyService --reload. Test with curl before adding complexity. This 5-minute loop validates your environment, model loading, and API contract before you invest in infrastructure.

  2. Start with --reload for development, but remove it for production. The reload mode watches your Python files and restarts the server on changes — essential for iteration. Once deployed, run without --reload for lower memory overhead and stable worker lifecycle.

  3. Enable adaptive batching from day one. Add batchable=True and set max_batch_size to the largest batch that fits in GPU memory. The latency cost (p50 +4-20ms) is negligible; the throughput gain (3-4x) is transformative. You cannot retrofit batching after shipping — it changes your API contract from single-input to batch-input.

  4. Run bentoml build early and often. The Bento artifact is BentoML’s unit of deployment. Building early catches missing dependencies (forgotten requirements.txt entries), missing model artifacts, and version conflicts. The lockfile in the Bento is reproducible — share it with your team instead of a Dockerfile.

  5. Use bentoml containerize for Docker, never hand-roll Dockerfiles. The containerize command generates an optimized image that uses BentoML’s runtime layers and health checks. Hand-written Dockerfiles almost always miss something (environment variables, probe endpoints, Python version pinning). If you need custom dependencies, use bentoml.images.Image() in Python instead of a separate Dockerfile.

What to Watch Out For

1. GPU memory planning is critical. Each worker loads a full copy of the model. With workers=2 and a 7B parameter model in FP16 (14GB), you need 28GB of GPU memory minimum. Always set gpu_memory in resources to prevent silent OOM.

Lesson learned: “We set workers=4 on a single A100 (80GB) for a 13B model. Each worker consumed ~26GB. Under load, memory fragmentation pushed us over 80GB and the driver killed all four workers simultaneously. The service was down for 8 minutes before we noticed. Now we set gpu_memory: "18Gi" per worker and run at most 3 workers per GPU.” — ML Infrastructure Engineer at a fintech startup

2. Adaptive batching has a latency floor. The max_latency_ms parameter is a hard upper bound, but the actual latency includes model inference time. If your model takes 200ms per inference and you set max_latency_ms=100, the batcher will never fill a batch — every request gets processed individually. Set max_latency_ms to at least 2-3x your model’s single-inference latency.

3. The Bento format is not a standard OCI container. You can’t docker pull a Bento and run it with a generic container runtime. You need the BentoML runtime (either via bentoml containerize or the BentoML Docker image). This means your CI/CD pipeline needs BentoML installed.

Lesson learned: “We tried to build Bentos in CI and push them to a Docker registry, expecting them to work like regular images. Turns out bentoml containerize is the only supported path to Docker. We wasted a week trying to reverse-engineer the Bento format before reading the docs.” — ML Platform Engineer at a Series B startup

4. Circus is not Kubernetes. Circus provides process management on a single node. If you need multi-node autoscaling, health checks across nodes, or rolling updates, you need Yatai or BentoCloud. BentoML’s single-node mode is production-ready for one machine, but don’t expect Kubernetes-level resilience without the orchestration layer.

5. Python version matters at build time. BentoML locks the Python version at bentoml build time. If you build on Python 3.12, the Bento runs on Python 3.12 everywhere. This is usually fine, but watch out for system-level Python version mismatches in custom Docker images.

6. The @bentoml.api(batchable=True) constraint is real. Batchable endpoints accept exactly one list parameter (plus bentoml.Context). If your endpoint needs multiple parameters, you must wrap them in a Pydantic model. This is a deliberate design choice — it forces clean batching semantics — but it means you can’t just add batchable=True to an existing multi-parameter endpoint.

Lesson learned: “We had a service with def predict(self, image: bytes, threshold: float) -> dict. Adding batchable=True broke because the batcher expects a single list parameter. We had to refactor to a Pydantic BatchInput(image: bytes, threshold: float) model and accept list[BatchInput]. It took 30 minutes but the throughput gain was 3x. Worth it, but surprising if you don’t read the docs first.” — Senior ML Engineer at an e-commerce company


Next in the Open-Source AI Tools Mastery series: Ray Serve

NL

Written by Nivant Labs Team

Engineer at Nivant Labs

Share this post