Edge inference serving¶
This example serves SSE token streams and MQTT device traffic in one process.
one Python process
┌──────────────────────────────────────────────────────────┐
│ :8000 HTTP/1.1 · HTTP/2 (h2c, or ALPN with TLS) │
│ GET /generate ──► SSE token stream │
│ GET /devices ──► latest readings (JSON) │
│ │
│ :1883 MQTT 5 broker │
│ sensors/{device}/{metric} ──► tap → in-memory │
│ jobs/# ──► $share/… round-robin work queue │
└──────────────────────────────────────────────────────────┘
browsers · curl · httpx devices · workers
The runnable version is
examples/edge_inference.py.
Use one worker for this example: its HTTP handlers and MQTT tap share
in-memory readings, while a multi-worker broker belongs only to worker 0.
The serving surface: SSE token streaming¶
Token-streaming inference is a plain streaming handler: iterate the model, yield one SSE event per token (see Streaming for the full surface):
from blackbull import BlackBull, EventSourceResponse
app = BlackBull()
@app.route(path='/generate')
async def generate(prompt: str = 'Hello, edge'):
async def events():
async for token in fake_model(prompt):
yield {'event': 'token', 'data': token}
yield {'event': 'done', 'data': ''}
return EventSourceResponse(events())
prompt resolves from the query string, which keeps the endpoint
compatible with the browser's EventSource API (GET-only). Each
yield is written to the wire as it happens — transport
backpressure, not buffering, paces the stream — so the first token
reaches the client while the rest are still being generated.
Concurrent generations are where HTTP/2 earns its place: several
/generate streams multiplex over one connection instead of
queueing head-of-line behind each other. No extra setup is needed —
cleartext h2c is detected from the connection preface:
curl --http2-prior-knowledge -N 'http://localhost:8000/generate?prompt=hi'
Browsers require HTTP/2 over TLS; pass certfile= / keyfile= to
app.run and ALPN negotiates h2 on the same port
(TLS deployment).
Swapping in a real model¶
The example's fake_model is an async generator that sleeps between
tokens. A real model is CPU-bound (or NPU/GPU-bound behind a
blocking call), and running it inline would stall the event loop —
every other stream, and the MQTT broker, would freeze between
tokens. Keep the loop free by pushing inference off-thread and
draining tokens through a queue:
import asyncio
async def real_model(prompt: str):
q: asyncio.Queue[str | None] = asyncio.Queue()
loop = asyncio.get_running_loop()
def run(): # blocking generation, worker thread
for token in llm.generate(prompt): # llama-cpp, ONNX, ...
loop.call_soon_threadsafe(q.put_nowait, token)
loop.call_soon_threadsafe(q.put_nowait, None)
task = asyncio.create_task(asyncio.to_thread(run))
try:
while (token := await q.get()) is not None:
yield token
finally:
task.cancel()
The serving code around it — the route, the SSE framing, the multiplexing — is unchanged.
Device ingest: the broker you already run¶
MQTTExtension puts a full MQTT 5 broker in the same
process. Devices connect to :1883 with any standard client;
on_message taps let the application observe the traffic — here,
keeping the latest reading per device for the HTTP side to report:
from blackbull.mqtt import MQTTExtension, Message
mqtt = app.add_extension(MQTTExtension(port=1883))
_readings: dict[str, dict[str, str]] = {}
@mqtt.on_message(topic='sensors/{device}/{metric}')
async def on_reading(msg: Message, device: str, metric: str):
_readings.setdefault(device, {})[metric] = msg.payload.decode()
@app.route(path='/devices')
async def devices():
return _readings
{device} and {metric} are captures — they match one topic level
like + and arrive as keyword arguments, the MQTT counterpart of
HTTP path params. Taps observe routing; delivery to subscribed MQTT
clients happens whether or not a tap matches.
Batch jobs: a work queue with no queue server¶
Interactive requests take the HTTP door; batch work takes the MQTT
door. Shared subscriptions (MQTT 5 §4.8.2) turn a topic into a
load-balanced work queue: every worker that subscribes to the same
$share/{group}/{filter} joins a share group, and the broker gives
each matching message to exactly one member, round-robin.
mosquitto_sub -t '$share/workers/jobs/#' -p 1883 -V 5 # worker 1
mosquitto_sub -t '$share/workers/jobs/#' -p 1883 -V 5 # worker 2
mosquitto_pub -t 'jobs/generate' -m '{"prompt": "job-1"}' -p 1883 -V 5
mosquitto_pub -t 'jobs/generate' -m '{"prompt": "job-2"}' -p 1883 -V 5
job-1 and job-2 land on different workers. Scaling the pool is
starting another subscriber — no queue server, no client library
beyond MQTT, and the broker is the process you already run. The
shared-subscriptions section of the MQTT guide
covers the exact semantics (QoS, skipped members, retained
messages).
Two honest caveats for queue duty:
- No offline buffering for an empty group. If no member is connected when a job is published, the job is dropped, not queued (the broker's general no-offline-queue behaviour). Keep at least one worker connected whenever producers are publishing.
- Queue state lives in this process. A broker restart clears in-flight session state; there is no persistence layer.
Deployment limits¶
BlackBull does not batch, schedule or accelerate model inference. Keep
blocking model calls off the event loop, bound queued tokens to the memory
budget, and stop the model when the consumer disconnects. Cancelling an
asyncio.to_thread task does not stop the running thread.
The broker is in memory, with one worker owner; see MQTT limitations.
Run it¶
pip install blackbull
python examples/edge_inference.py
Then, in other terminals:
curl -N 'http://localhost:8000/generate?prompt=hello+edge' # SSE tokens
open http://localhost:8000/ # browser demo
mosquitto_pub -t 'sensors/pi-cam-1/temperature' -m '41.2' -p 1883 -V 5
curl http://localhost:8000/devices
The example is dependency-free — the "model" is canned tokens — so it runs anywhere, including the box you're about to deploy to.