MLServe

Production machine-learning inference for the BEAM.
OTP supervision, worker pools, dynamic batching, caching, telemetry and zero-downtime model rollout — around any ML backend.

Hex versionHexDocsDownloadsCIMIT


Elixir has excellent model libraries — Nx, Bumblebee, Ortex, EXLA. What it has not had is the boring layer around them: the thing that supervises a model, pools access to it, batches requests, caches results, reports latency, and lets you swap a model version without a deploy.

Every team that puts a model into a Phoenix app rebuilds that layer by hand. MLServe is that layer, and nothing else. It does not train, it does not own a tensor type, and it is not another LLM API wrapper.

Works with any Elixir ML backendNx and Nx.Serving, Bumblebee, ONNX Runtime via Ortex, scikit-learn or PyTorch exports, a Python process over a port, or a remote HTTP model server. If it can be reached from a function, MLServe can supervise, pool, batch, cache, measure and version it.

MLServe.predict(:fraud_detection, %{
amount: 1500.50,
transaction_count_24h: 8,
failed_transactions_24h: 2
})
#=> {:ok, %{prediction: :fraud, probability: 0.94}}

Installation

Add ml_serve to your dependencies in mix.exs:

def deps do
[{:ml_serve, "~> 0.1.0"}]
end
mix deps.get

Requires Elixir 1.14 or later. The only runtime dependency is :telemetry.

Why it exists

Inference serving is a concurrency problem wearing a machine-learning hat: parallel requests, stateful and expensive-to-load models, backends that are not thread-safe, failures that must stay contained, and versions that must change without downtime. That list is OTP's home ground.

What that buys you concretely:

Quick start

Any module with load/1 and predict/2 is a model:

defmodule MyApp.FraudModel do
@behaviour MLServe.Model
@impl true
def load(config), do: {:ok, Keyword.fetch!(config, :threshold)}
@impl true
def predict(threshold, %{amount: amount}) do
probability = min(amount / 2000, 1.0)
{:ok, %{
prediction: if(probability > threshold, do: :fraud, else: :legitimate),
probability: probability
}}
end
end

Register it:

config :ml_serve,
models: [
fraud_detection: [
backend: MyApp.FraudModel,
version: "1.0.0",
workers: 4,
config: [threshold: 0.7]
]
]

…or at runtime, which is the same code path:

MLServe.load_model(:fraud_detection, backend: MyApp.FraudModel, config: [threshold: 0.7])

Then predict from anywhere — a controller, an Oban job, a Task:

case MLServe.predict(:fraud_detection, %{amount: 1500.50}) do
{:ok, result} -> result
{:error, reason} -> Logger.warning("inference failed: #{inspect(reason)}")
end

Architecture

MLServe.Supervisor (:rest_for_one)
├── MLServe.Registry process registry, partitioned by scheduler
├── MLServe.ModelRegistry owns the catalog ETS table
├── MLServe.Cache owns the cache ETS table + TTL sweeper
├── MLServe.TaskSupervisor batch fan-out
└── MLServe.ModelSupervisor DynamicSupervisor
└── MLServe.ModelInstance one subtree per loaded {name, version}
├── MLServe.ModelServer lifecycle, status, graceful drain
├── MLServe.WorkerSupervisor
│ └── MLServe.Worker × N
└── MLServe.Batcher when dynamic batching is configured

:rest_for_one at the top is deliberate: MLServe.ModelRegistry owns the catalog ETS table, and ETS tables die with their owner. If it restarts, every route has evaporated and any model still running would be serving traffic the registry no longer knows about.

The prediction path itself contains no MLServe process at all:

route lookup (1 ETS read) → admission control (atomic counter) → telemetry span opens
→ cache lookup → preprocess hook → dispatch → postprocess hook → cache write

Cache, hooks and validation all run before dispatch, so a worker is occupied only for actual inference. A preprocess hook that queries Postgres for stored features runs on the caller's own scheduler time, never on a GPU worker's.

See the Architecture guide for the full picture.

Concurrency

A backend declares how it may be executed, and MLServe picks a completely different strategy:

concurrencyWhere predict/2 runsWhere state livesUse for
:sharedThe calling process — no processes, no copies:persistent_termNx.Serving, Bumblebee, pure functions, remote services
:exclusiveA pooled workerWorker state, or a shared handleONNX Runtime sessions, Python ports, anything not thread-safe

Crossed with when the model loads:

loadMeaning
:once (default)load/1 runs once; the state term is shared by all workers. Correct for NIF-resource models, where the term is a cheap handle.
:per_workerload/1 runs per worker. Correct for ports and per-worker sessions.

:once as the default matters — loading a 2 GB model separately into eight workers is an out-of-memory crash, not a pool.

models: [fraud_detection: [workers: 8, selection: :least_loaded, max_concurrency: 64]]

Model lifecycle

MLServe.load_model(:fraud, backend: MyApp.Model, version: "2.1.0")
MLServe.await_ready({:fraud, "2.1.0"}) # loading is async; boot is never blocked
MLServe.canary(:fraud, "2.1.0", 5) # 5% of traffic, tagged in telemetry
MLServe.promote(:fraud, "2.1.0") # atomic pointer flip, no restart
MLServe.unload_model(:fraud, version: "1.0.0") # drains in-flight requests first
MLServe.model_status(:fraud)
#=> {:ok, %{status: :ready, workers: 4, in_flight: 3, requests: 154_223, errors: 12, ...}}

Loading is asynchronous and retried with backoff, so a model on a network mount that attaches a moment after the container starts does not take your application down with it. MLServe.ready?/1 is your readiness probe.

Batch inference

MLServe.batch_predict(:fraud_detection, [features_a, features_b, features_c])

One backend call when the backend implements batch_predict/2, a mapped predict/2 otherwise.

For many concurrent callers rather than one caller with many inputs, enable dynamic batching:

models: [fraud_detection: [batching: [max_size: 16, timeout: 10]]]

Independent predict/3 calls arriving within the window are coalesced into a single backend invocation. Watch [:ml_serve, :batch, :flush] — a healthy setup flushes mostly on :full.

Telemetry

[:ml_serve, :prediction, :start | :stop | :exception]
[:ml_serve, :model, :load] [:ml_serve, :model, :unload]
[:ml_serve, :cache, :hit] [:ml_serve, :cache, :miss]
[:ml_serve, :batch, :flush]

Measurements include duration, queue_duration, inference_duration and batch_size; metadata carries model, version, backend, cached? and canary?.

MLServe.Telemetry.Logger.attach(level: :info) # zero-dependency visibility
MLServe.Telemetry.Metrics.metrics() # Telemetry.Metrics / LiveDashboard

Because version and canary? ride along on every event, comparing a canary against the incumbent needs no extra instrumentation — which is what makes a promote/rollback decision possible.

Backends

MLServe ships two dependency-free backends: MLServe.Backend.Function (wrap any function or MFA) and MLServe.Backend.Static (fixed result — swap it in config/test.exs to test your app without a model).

Real ML runtimes are guides with complete implementations, not dependencies, so the core stays at one runtime dep and backends version independently:

Phoenix integration

Phoenix is not a dependency. It does not need to be:

defmodule MyAppWeb.MLController do
use MyAppWeb, :controller
def predict(conn, params) do
case MLServe.predict(:fraud_detection, params) do
{:ok, result} -> json(conn, result)
{:error, reason} -> conn |> put_status(status_for(reason)) |> json(%{error: inspect(reason)})
end
end
end

See the Phoenix guide for status-code mapping, LiveDashboard metrics and readiness probes, and the Oban guide for asynchronous inference with retry semantics driven by MLServe.Error.retryable?/1.

Errors

Every function returns {:ok, result} or {:error, reason}; bang variants raise MLServe.Error.

ReasonMeaning
:model_not_foundNot registered under that name or version
:model_not_readyRegistered, but loading, draining or failed
:timeoutThe deadline passed before inference completed
:overloadedAt the :max_concurrency limit
{:invalid_input, reason}Rejected by a :preprocess hook
{:batch_too_large, max}Batch exceeded :max_batch_size
{:backend_error, %MLServe.BackendError{}}The backend raised — exception and stacktrace preserved
{:load_failed, reason}The model could not be loaded

Backend failures are never swallowed: a raise is caught only to attach the model, version, callback and stacktrace before surfacing it.

Security

Common questions

How do I serve an ONNX model in Elixir?

Wrap an Ortex session in a MLServe.Model backend and MLServe supplies the pool, batching, checksum verification and telemetry around it. There is a complete runnable example in examples/onnx — including HuggingFace all-MiniLM-L6-v2 doing real semantic search — and a full implementation in Creating a Model Backend.

How do I run Bumblebee or Nx.Serving in production Phoenix?

Declare the backend concurrency: :shared. Inference then runs in the Phoenix request process with state read from :persistent_term — no worker pool, no message copies of your tensors, and no serialisation point. See Concurrency and Phoenix Integration.

Is this an Elixir alternative to TensorFlow Serving or TorchServe?

For the serving concerns, yes — supervision, pooling, dynamic batching, caching, metrics, versioning and canary rollout — but in-process on the BEAM instead of as a separate service you deploy, scale and monitor. You keep one deployable and lose a network hop. It does not bring its own model runtime; you point it at Nx, ONNX Runtime, or whatever you already use.

Can I deploy a new model version without a deploy?

Yes — that is the point of MLServe.load_model/2, MLServe.canary/3 and MLServe.promote/2. Load the new version beside the running one, send it a percentage of traffic, compare per-version telemetry, then promote with a single ETS write. In-flight requests finish on the version they started on. See Model Versioning.

How do I test application code that calls a model?

Point the model at MLServe.Backend.Static in config/test.exs. Your suite then exercises the real routing, caching, telemetry and error handling with no model file, no ML runtime and no mocking library — see examples/inference_service, whose tests do exactly this.

What does it cost on the hot path?

One lock-free ETS read in the calling process. MLServe.ModelRegistry owns the catalog but is never in the request path, and the only MLServe process involved in a prediction is the worker running inference — none at all for :shared backends. The single runtime dependency is :telemetry.

Documentation

Full documentation is on HexDocs, including guides for Getting Started, Architecture, Creating a Model Backend, Running Inference, Batch Inference, Concurrency, Telemetry, Model Versioning, Phoenix, Oban and Production Deployment.

Examples

examples/ has runnable code for everything above — it lives on GitHub rather than in the Hex package:

For AI assistants and LLM tooling

usage-rules.md is a condensed, machine-readable summary of the public API and its constraints — including the mistakes that are easy to make — and llms.txt points at the full documentation set. Point your coding agent at either; both ship inside the Hex package.

Contributing

Issues and pull requests are welcome. Before submitting:

mix lint # format --check-formatted + compile --warnings-as-errors + credo --strict
mix test
mix dialyzer

License

Released under the MIT License. Copyright © 2026 James Njovu.