Real-time streaming ML inference server with adaptive batching, backpressure, and model hot-swap.
I built this after spending too much time at Observe.AI dealing with the gap between "model works in a notebook" and "model serves 500 concurrent users." Most inference servers either batch too aggressively (high latency) or not at all (low throughput). StreamInfer finds the sweet spot automatically.
- WebSocket streaming — clients send data, get predictions back in real-time
- Adaptive batching — accumulates requests and flushes on batch_size OR timeout, whichever hits first
- Backpressure — token bucket rate limiter + slow consumer detection prevents one bad client from killing the server
- Model hot-swap — swap models with zero downtime via SIGHUP or API call
- Metrics — in-memory counters exposed at
/metrics(no Prometheus dependency needed)
┌─────────────┐
WebSocket ──────┐ │ │
Client 1 │ ┌──────────┐ │ Adaptive │ ┌───────────┐
├───→│Backpress.│─→│ Batcher │─→│ Model │
WebSocket ──────┤ │(per-client│ │ │ │ Holder │
Client 2 │ │rate limit)│ │ flush on: │ │ (swap │
│ └──────────┘ │ • batch full │ │ via lock)│
POST /predict ──┘ │ • timeout │ └───────────┘
└─────────────┘
Fixed batching forces a tradeoff: small batches waste GPU cycles, large batches add latency. The adaptive approach collects items into a batch and flushes when EITHER:
- The batch is full (throughput-optimal)
- A timeout fires (latency-bounded)
This means: at high load you get full batches (good throughput), at low load you get fast responses (low latency). Same idea as Triton Inference Server's dynamic batcher, but ~100 lines of Python instead of a C++ behemoth.
Each WebSocket client gets a token bucket rate limiter. If a client sends faster than the configured rate, excess requests get a rate_limited response with a retry_after_ms hint. If a client's pending queue exceeds 80% capacity, they get a consumer falling behind warning.
Without this, one runaway client can fill the server's memory with pending requests until it OOMs.
pip install -e "."
# start server (uses echo model by default)
python -m streaminfer.server
# in another terminal — send some requests
python examples/client.pydocker build -t streaminfer .
docker run -p 8000:8000 streaminferAll settings via environment variables (prefix STREAMINFER_):
| Variable | Default | Description |
|---|---|---|
STREAMINFER_BATCH_SIZE |
16 | Max items per batch |
STREAMINFER_BATCH_TIMEOUT_MS |
50 | Flush timeout in ms |
STREAMINFER_MAX_QUEUE_SIZE |
1000 | Per-client queue limit |
STREAMINFER_RATE_LIMIT_RPS |
100 | Requests/sec per client |
STREAMINFER_MODEL_NAME |
echo | Model to load at startup |
STREAMINFER_PORT |
8000 | Server port |
| Endpoint | Method | Description |
|---|---|---|
/ws |
WebSocket | Streaming inference — send JSON, get JSON back |
/predict |
POST | Single request/response (still batched internally) |
/metrics |
GET | Server metrics as JSON |
/api/reload |
POST | Hot-swap model: {"model": "upper"} |
/health |
GET | Health check |
Three ways to swap the model with zero downtime:
# 1. API call
curl -X POST localhost:8000/api/reload -d '{"model": "upper"}'
# 2. SIGHUP (reloads from config)
kill -HUP $(pgrep -f streaminfer)
# 3. The swap is atomic — old model finishes in-flight requests,
# new model handles all new ones. No requests dropped.With echo model on M1 MacBook (not a fair GPU benchmark, but shows the batching works):
connections: 100
requests: 50 per connection
total: 5000 requests in 4.2s
throughput: 1190 req/s
latency p50: 12.3ms
latency p95: 34.7ms
latency p99: 48.2ms
pip install -e ".[dev]"
pytest tests/ -vMIT