| title | Writing Unified Backends |
|---|---|
| subtitle | Choose Python or Rust for Dynamo's shared backend contract |
Dynamo's unified backend path lets custom engines implement the same lifecycle contract used by the built-in backends. The engine owns inference; Dynamo owns runtime registration, request serving, cancellation monitoring, signal handling, drain, and graceful shutdown.
Use this path for new token-in-token-out engines unless you need a feature that is still outside the unified contract.
| Path | Use when |
|---|---|
| Python unified backend | Your engine or serving library is Python-first, or you want the quickest path to integrate a custom engine. |
| Rust unified backend | You want a native Rust binary, tighter control of runtime dependencies, or no Python worker runtime. |
| Python workers (lower-level) | You need custom request handling or features not yet covered by the unified backend contract. Use Python Workers. |
Both unified implementations follow the same shape:
parse config -> start engine -> stream generated chunks -> abort/drain -> cleanup
The framework handles model registration, endpoint serving, cancellation plumbing, and shutdown behavior around that engine contract.
Supported today:
- aggregated token-in-token-out inference
- disaggregated serving modes for supported engines
- model registration through Dynamo discovery
- request cancellation
- structured backend errors
- graceful shutdown and drain hooks
Still use the lower-level Python worker path when you need features such as multimodal requests, LoRA adapter management, logprobs, guided decoding, engine-specific routes, custom request handling, or features that need direct control of the request payload.
After you implement the backend, package it into a runtime image with
Runtime Containers. For Kubernetes deployment, place the
custom backend in a DynamoGraphDeployment and follow the
Deployment Overview.
New — Dynamo's unified backend. This guide covers the new unified backend infrastructure in
dynamo.common.backend: a sharedLLMEngineABC that vLLM, SGLang, TRT-LLM, and a sample engine already implement, and that any custom Python engine can plug into the same way. For the Rust version of the same contract, use the Rust tab on this page. For the older lower-level Python worker path (register_model+serve_endpoint) — still the right choice for features the unified backend does not yet cover — see Writing Python Workers.Beta — actively under development. The unified backend surface is beta quality and may change without backwards compatibility between releases. See Feature gaps below for what the unified path covers today versus the existing (non-unified) backend paths.
This guide walks through building a Python backend for an inference
engine that plugs into Dynamo's distributed runtime via
dynamo.common.backend. A "unified backend" is a Python entry point
that implements the shared LLMEngine ABC and lets the framework own
runtime lifecycle (signal handling, model registration, graceful
shutdown, cancellation monitoring) — your code just owns inference.
Your backend lives in its own package and does not need to be part
of the dynamo repository. It depends on ai-dynamo from PyPI (or
the git source) and imports dynamo.common.backend. The steps below
assume you're starting a fresh package in your own repo.
The reference example is the sample engine at
sample_engine.py
— a complete, runnable implementation under 120 lines. Read it
alongside this guide.
Where to look for what:
- This guide — step-by-step walkthrough for someone starting a new backend from scratch.
LLMEngineABC docstrings — authoritative method-by-method contract.- Package README
— in-tree reference:
GenerateRequest/GenerateChunkfield definitions, per-engine cancellation cookbook (vLLM / SGLang / TRT-LLM), fullDynamoExceptiontable, file index, and the per-engine feature-gap matrix.
The unified backend is in beta. The summary below is the common contract — what every engine on the unified path gets — plus the gaps that apply to all three engines. Per-engine specifics (vLLM sleep/wake, SGLang diffusion, TRT-LLM custom logits processors, etc.) live in the package README.
Supported today
Lifecycle and runtime:
- Aggregated token-in-token-out inference
- Disaggregated serving (
agg/prefill/decode) — KV transfer uses NIXL across all three engines; SGLang exchanges a Dynamo-level bootstrap address (host/port/room), vLLM and TRT-LLM use an engine-internal handshake - Model registration with discovery and endpoint types
- Request cancellation via
abort()+context.is_stopped() - Graceful shutdown with signal handling
drain()hook for pre-cleanup work (e.g. in-flight NIXL transfers)DynamoExceptionerror chain wrapping- Finish reason normalization (handled by the Rust layer)
- Engine control plumbing, with per-backend profiling, quiesce/resume, and supported weight-update controls
Observability:
- Health-check canary via
health_check_payload()(plusDYN_HEALTH_CHECK_PAYLOAD/--health-check-payloadoverrides) - Vendor-prefixed Prometheus bridge (
vllm:/sglang:/trtllm_/lmcache:) viaregister_prometheus() - Framework-owned lifecycle gauges (
cleanup_time_seconds,drain_time_seconds,model_load_time_seconds) — always on - Per-rank
dynamo_component_*gauges + routerkv_used_blockssignal viacomponent_metrics_dp_ranks()+attach_snapshot_publisher()+ComponentSnapshotpush - KV event publishing via
kv_event_sources()returningZmqSourceorPushSource - KV-aware routing (DP-rank-aware) via
dp_rank.forced_dp_rank/validate_global_dp_rank+EngineConfig.data_parallel_{size, start_rank} - OpenTelemetry tracing facade —
telemetry.current_span/start_spanplus W3C trace header propagation throughtelemetry.engine_trace_kwargs(context)
Request handling:
- Guided decoding — wired per-engine on the request side with
engine-specific coverage. vLLM (
StructuredOutputsParams) and TRT-LLM (GuidedDecodingParams) cover JSON schema / regex / grammar / choice; SGLang (_get_guided_decoding_params) covers JSON schema only — regex / grammar / choice are silently dropped today (see the SGLang-specific gaps in the package README) - Structural tag generation via
WorkerConfig.structural_tag_{mode, scope, schema}andserialize_structural_tag - Custom Jinja chat templates via
WorkerConfig.custom_jinja_template(frontend applies; the backend advertises through model registration) - Tool / reasoning parser configuration (
tool_call_parser,reasoning_parser,exclude_tools_when_tool_choice_none)
Not yet on the unified path (common to all engines)
| Feature | What's missing |
|---|---|
| Logprob response wire | Legacy handlers extract logprobs onto response chunks (vLLM _extract_logprobs, SGLang _extract_logprobs in decode_handler, TRT-LLM _extract_logprobs in handler_base); the unified generate() loops do not populate log_probs / top_logprobs / cum_log_probs on GenerateChunk. vLLM's build_sampling_params still passes output_options.logprobs to the engine on the unified path, so the engine computes them, but the values are dropped before they reach the chunk. SGLang and TRT-LLM unified generate() do not read output_options.logprobs at all. |
| Text-in-text-out mode | Unified hardcodes ModelInput.Tokens; no engine-side tokenization or chat templating path |
| Multimodal | Images / video / embeddings, NIXL embedding transfer, separate encode workers, ENCODE disaggregation role |
| Diffusion | Image (FLUX), video (Wan2.1), LLM diffusion (DLLM) workers; no diffusion engine, MediaOutput, or media scheduling on the unified path |
| LoRA adapters | Dynamic load / unload / list, ModelDeploymentCard publishing, per-adapter serialization locks, per-request adapter threading on prefill |
| Snapshot / checkpoint | CRIU-based engine state save/restore + identity reload |
If you need one of these features today, keep that workload on the
existing per-engine entry point (dynamo.<backend>.main) until the
unified path catches up.
A backend is two things:
- An engine class that subclasses
LLMEngine— owns the model, accepts preprocessed token requests, streams output chunks. - A
main.pyentry point — a three-line shim that hands the engine class torun()fromdynamo.common.backend.run, which drives the lifecycle.
The dynamo.common.backend package handles everything else: signal
handling, distributed runtime setup, model registration with
discovery, the serving loop, graceful shutdown, cancellation
monitoring, and error chain wrapping. (The lifecycle state machine
actually lives in Rust; dynamo.common.backend.Worker is a thin
Python shim over it.)
from_args → start() → generate() / abort() → drain() → cleanup()
| | | | |
parse argv, start engine, serve requests pre-cleanup release
return return (concurrent) drain resources
engine metadata
- Python 3.11 or newer.
dynamousestyping.Required, which is 3.11+. - NATS and etcd reachable for end-to-end runs. The dynamo repo's
deploy/docker-compose.ymlbrings up both in one command if you don't already have them running. uvorpipfor installing dependencies.- Familiarity with
asyncPython (asyncio, async generators) andargparse.
my-backend/
├── pyproject.toml
└── src/
└── my_backend/
├── __init__.py
├── engine.py
└── main.py
Minimal pyproject.toml:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-backend"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
# ai-dynamo bundles dynamo.common.backend. Pin to the release whose
# LLMEngine contract you tested against — the surface is still beta
# and may change between releases.
"ai-dynamo>=1.2.0",
]
[project.optional-dependencies]
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
[project.scripts]
my-backend = "my_backend.main:main"For a bleeding-edge dependency on the dynamo source tree, install the runtime wheel from a clone:
git clone https://github.com/ai-dynamo/dynamo.git
pip install maturin
cd dynamo/lib/bindings/python && maturin build --release --out /tmp/wheels
pip install /tmp/wheels/*.whl # ai-dynamo-runtime
pip install /path/to/dynamo # ai-dynamo (components/ tree)Building the wheel needs a Rust toolchain plus clang, cmake,
protobuf-compiler, and libssl-dev.
In src/my_backend/engine.py, declare a class that subclasses
LLMEngine and owns whatever state your engine needs. Construction
must be cheap and side-effect-free — heavy work goes in start().
# src/my_backend/engine.py
from __future__ import annotations
import argparse
import asyncio
from collections.abc import AsyncGenerator
from dynamo._core import Context
from dynamo.common.backend import (
EngineConfig,
GenerateChunk,
GenerateRequest,
LLMEngine,
WorkerConfig,
)
class MyBackend(LLMEngine):
def __init__(self, model_name: str, max_tokens: int = 16):
self.model_name = model_name
self.max_tokens = max_tokens
# Heavy state (engine handles, schedulers, KV allocators) is
# left None here and initialized in start().
self._engine = NoneGenerateRequest and GenerateChunk are TypedDicts describing the
shared shape — see Step 4 for the fields.
from_args is a classmethod factory that parses CLI args and returns
(engine, WorkerConfig). The engine is constructed but not
started.
@classmethod
async def from_args(
cls, argv: list[str] | None = None
) -> tuple[MyBackend, WorkerConfig]:
parser = argparse.ArgumentParser(prog="my-backend")
parser.add_argument("--model-name", default="my-model")
parser.add_argument("--max-tokens", type=int, default=16)
# Runtime / discovery flags — every unified backend needs these.
parser.add_argument("--namespace", default="dynamo")
parser.add_argument("--component", default="backend")
parser.add_argument("--endpoint", default="generate")
parser.add_argument("--endpoint-types", default="chat,completions")
parser.add_argument("--discovery-backend", default="etcd")
parser.add_argument("--request-plane", default="tcp")
parser.add_argument("--event-plane", default=None)
args = parser.parse_args(argv)
engine = cls(model_name=args.model_name, max_tokens=args.max_tokens)
worker_config = WorkerConfig(
namespace=args.namespace,
component=args.component,
endpoint=args.endpoint,
model_name=args.model_name,
served_model_name=args.model_name,
endpoint_types=args.endpoint_types,
discovery_backend=args.discovery_backend,
request_plane=args.request_plane,
event_plane=args.event_plane,
)
return engine, worker_configfrom_args is async to match the ABC; you can await from it if
your CLI parsing reads config from a file or hits an API. Most
backends don't need to.
For backends that already have a DynamoRuntimeConfig-shaped
config object (e.g. ones derived from vLLM's, SGLang's, or
TRT-LLM's existing config), prefer the
WorkerConfig.from_runtime_config(runtime_cfg, model_name=...)
helper — it pulls the shared discovery / request-plane / parser
fields off the config in one line.
The ABC has three required methods (start, generate, cleanup)
plus two with default no-op implementations (abort, drain).
Start the engine and return EngineConfig metadata. After this
returns, generate() MUST be ready for concurrent calls.
async def start(self, worker_id: int) -> EngineConfig:
# ... load weights, build scheduler, warm up CUDA, etc.
# Heavy: may take minutes. Emit logger.info checkpoints so
# operators see progress (Worker logs around start() but not
# inside it).
self._engine = await heavy_init(self.model_name)
return EngineConfig(
model=self.model_name,
served_model_name=self.model_name,
context_length=8192,
kv_cache_block_size=16, # None if no block-structured KV
total_kv_blocks=1024,
max_num_seqs=64,
max_num_batched_tokens=8192,
)worker_id is an opaque per-worker identifier — most engines ignore
it. Backends needing a stable cluster-wide key (e.g. TRT-LLM's
disagg_machine_id snowflake) should derive from it instead of
hashing host/pid or asking operators for a CLI override.
Every EngineConfig field except model is optional. None means
"don't advertise"; KV-aware routing falls back to round-robin when KV
fields are unset.
An async generator that yields GenerateChunk dicts for a single
request. Called concurrently for multiple in-flight requests.
Contract (chunk shape is defined by the GenerateChunk TypedDict
— see
Request / Response Types
in the package README for the field reference):
- Every chunk carries
token_idsandindex(use0for single choice). - The final chunk additionally carries
finish_reasonandcompletion_usage. - The framework's cancellation monitor calls
engine.abort(context)when the client disconnects or cancels; your loop should also pollcontext.is_stopped()between yields and exit cleanly with afinish_reason="cancelled"chunk.
async def generate(
self, request: GenerateRequest, context: Context
) -> AsyncGenerator[GenerateChunk, None]:
prompt_tokens = list(request.get("token_ids", []))
prompt_len = len(prompt_tokens)
stop_conditions = request.get("stop_conditions") or {}
max_new = stop_conditions.get("max_tokens") or self.max_tokens
def _usage(completion_tokens: int) -> dict[str, int]:
return {
"prompt_tokens": prompt_len,
"completion_tokens": completion_tokens,
"total_tokens": prompt_len + completion_tokens,
}
for i in range(max_new):
if context.is_stopped():
yield {
"token_ids": [],
"index": 0,
"finish_reason": "cancelled",
"completion_usage": _usage(i),
}
return
token_id = await self._next_token(prompt_tokens)
chunk: GenerateChunk = {"token_ids": [token_id], "index": 0}
if i == max_new - 1:
chunk["finish_reason"] = "length"
chunk["completion_usage"] = _usage(max_new)
yield chunkFinish reason normalization ("abort" → "cancelled", etc.) is
handled by the Rust layer — emit whatever your engine uses
natively.
Called by the framework only when the client disconnects or the request is cancelled. NOT called on silent stream drops. Override to release engine-side resources (KV slots, scheduler entries, remote schedulers):
async def abort(self, context: Context) -> None:
request_id = context.id()
await self._engine.cancel(request_id)For cleanup that must run on every drop path — including silent
drops — use a try/finally or a context manager inside generate,
not abort. The sample engine doesn't override abort because it
has no engine-side state to release; the default is a no-op.
Runs once before shutdown, after the discovery unregister + grace-period sleep, while NATS/etcd are still alive. Use it for backend-side draining that must complete before transport teardown (e.g. in-flight NIXL KV transfers on prefill workers). Default is no-op.
Two real requirements, both pinned by the Rust-side conformance kit:
- Null-safe against partial
start()failure. Ifstart()raises partway through, fields you allocate incrementally may still beNone.cleanup()must guard each resource (if self._engine is not None: …) so the post-failure call doesn't crash on half-initialized state. - Idempotent. A second call after a successful first must return cleanly without re-entering teardown.
The Rust Worker drives both: it calls cleanup() after start()
returns Ok on shutdown, and the conformance kit (run_conformance)
additionally calls cleanup() on a never-started engine and twice in a
row, failing your tests with CleanupWithoutStartFailed /
SecondCleanupFailed if either invariant breaks. The guarded
single-shot pattern below covers both:
async def cleanup(self) -> None:
if self._engine is not None:
await self._engine.shutdown()
self._engine = NoneThree lines.
# src/my_backend/main.py
from dynamo.common.backend.run import run
from .engine import MyBackend
def main() -> None:
run(MyBackend)
if __name__ == "__main__":
main()run installs signal handlers, builds the distributed runtime,
calls engine.start(worker_id) with a runtime-allocated identifier,
registers the model with discovery, serves the endpoint, and runs the
graceful-shutdown orchestrator on SIGTERM/SIGINT.
Pair this with the [project.scripts] entry from Step 1's
pyproject.toml so my-backend ... works as a console command.
Errors: the framework wraps non-DynamoException errors raised
from generate() (or lifecycle methods) as Unknown. For typed
error reporting, raise a DynamoException subclass directly from
dynamo.llm.exceptions
— it propagates unchanged through the Rust bridge:
from dynamo.llm.exceptions import InvalidArgument
async def generate(self, request, context):
if not request.get("token_ids"):
raise InvalidArgument("empty prompt")
...The package README has the full table of exception types and which
lifecycle phase raises which one. Engine-init failures should raise
EngineShutdown from start(). Cleanup shouldn't normally raise —
log and swallow if a subsystem fails.
Logging: keep levels consistent across unified backends so operators see the same surface regardless of which engine they're running:
logger.info— lifecycle milestones (engine init complete, serving started, engine shutdown).logger.debug— per-request events (request abort, cancellation).logger.warning— recoverable problems (empty outputs, unexpected finish reasons).logger.error— unrecoverable failures only.
The framework also configures dynamo.runtime.logging for you; you
just call logger = logging.getLogger(__name__) at the top of your
module and use it.
Install the dev extras (pytest, pytest-asyncio) declared in Step 1:
pip install -e ".[dev]"The sample engine has a unit-test suite that you can copy as a starting point. The shape of a useful test:
import pytest
from my_backend import MyBackend
class _StubContext:
def __init__(self, stopped: bool = False) -> None:
self._stopped = stopped
def is_stopped(self) -> bool:
return self._stopped
def stop(self) -> None:
self._stopped = True
@pytest.mark.asyncio
async def test_generate_emits_terminal_chunk():
engine = MyBackend(model_name="m", max_tokens=3)
await engine.start(worker_id=0)
try:
chunks = [
chunk
async for chunk in engine.generate(
{"token_ids": [1, 2, 3]}, _StubContext()
)
]
assert chunks[-1]["finish_reason"] in ("stop", "length")
assert chunks[-1]["completion_usage"]["completion_tokens"] == 3
finally:
await engine.cleanup()
@pytest.mark.asyncio
async def test_generate_observes_cancellation():
engine = MyBackend(model_name="m", max_tokens=1000)
await engine.start(worker_id=0)
try:
ctx = _StubContext()
collected = []
async for chunk in engine.generate({"token_ids": [1]}, ctx):
collected.append(chunk)
if len(collected) >= 2:
ctx.stop()
assert collected[-1]["finish_reason"] == "cancelled"
finally:
await engine.cleanup()Cover the happy path, cancellation, and any backend-specific edge
cases (stop tokens, max-tokens cap, empty prompt). Three to five
focused tests is plenty — the framework already pins the lifecycle
state machine and cancellation contract with Rust-side tests in
lib/backend-common.
Three moving parts need to come up: NATS + etcd (discovery and the event/request planes), the Dynamo frontend (HTTP → backend discovery), and your backend.
pip install -e .
# Ensure NATS + etcd are reachable (NATS_SERVER, ETCD_ENDPOINTS).
# --model-name must be a valid HuggingFace repo (or local path); the
# framework fetches the tokenizer + chat template from it on startup.
# Pick a small public repo for smoke tests.
my-backend --model-name Qwen/Qwen3-0.6B --namespace dynamo
# In another shell, start the Dynamo frontend:
python -m dynamo.frontend --http-port 8000Then send a request:
curl http://localhost:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "Qwen/Qwen3-0.6B",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 32
}'A successful response has non-empty choices[0].message.content
and a finish_reason of stop or length.
jq -e '.choices[0].finish_reason' is a good one-liner for a CI
smoke test.
If your backend looks silent, set DYN_LOG=info (or
DYN_LOG=debug,dynamo=debug for finer scoping) before launching —
the framework configures tracing from DYN_LOG.
sample_engine.py
is the canonical minimal reference. Run it as-is:
python -m dynamo.common.backend.sample_main --model-name test-modelIt generates rotating token IDs with no ML dependencies, so it's a useful stand-in for AIPerf / end-to-end pipeline smoke tests. Lift these patterns:
from_argsparses CLI args and returns(engine, WorkerConfig)with no awaits.start()returns anEngineConfigwhose KV fields are illustrative but not load-bearing (no real KV cache).generate()pollscontext.is_stopped()between yields and emits acancelledterminal on observation.cleanup()is a no-op because the engine holds no resources.
Before shipping:
-
LLMEnginesubclassed;from_argsreturns(engine, WorkerConfig). -
start()returnsEngineConfigwith at least a non-emptymodel. -
generate()pollscontext.is_stopped()between yields and emits a"cancelled"terminal on observation. - Final chunk has
finish_reasonandcompletion_usage. - Typed
DynamoExceptionsubclasses used for error reporting where the category matters. -
cleanup()releases all engine resources. - Logging levels match the standards in Step 6.
LLMEngineABC — authoritative contract.- Package README — feature gaps, error model, request/response contract.
- Sample engine — example user guide.
- Rust tab on this page — the Rust counterpart, same contract, lower-level.
New — Dynamo's unified backend. This guide covers the new unified backend infrastructure in
dynamo-backend-common: a sharedLLMEnginecontract that vLLM, SGLang, TRT-LLM, and the mocker already implement, and that any custom engine can plug into the same way.Beta — actively under development. The Rust native backend surface is beta quality and may change without backwards compatibility between releases. See Feature gaps below for what the unified path covers today versus the existing (non-unified) backend paths.
This guide walks through building a Rust unified backend for an
inference engine that plugs into Dynamo's distributed runtime. A
unified backend is a standalone Rust binary that owns its engine and
serves requests via the shared
LLMEngine contract in
dynamo-backend-common — no Python
worker runtime required. For the Python version of the same contract
use the Python tab on this page.
Your backend lives in its own crate and does not need to be part of
the dynamo repository. It pulls dynamo-backend-common in as a normal
git or path dependency. The steps below assume you're starting a fresh
crate in your own repo; an optional note in Step 1 covers the in-tree
variant for contributors landing a backend inside ai-dynamo/dynamo.
For a Python engine, use the Python tab on this page — same contract, lighter setup. The non-unified fallback for feature gaps (multimodal, LoRA, logprobs, etc.) is Python-only; see Writing Python Workers if you need one of those today.
The reference example is the mocker backend at
lib/backend-common/examples/mocker
— a small, complete, pure-Rust implementation. Read it alongside this
guide.
Where to look for what:
- This guide — step-by-step walkthrough for someone starting a new backend from scratch.
LLMEnginetrait doc comments — authoritative method-by-method contract.- Crate README — in-tree reference: architecture, file index, disaggregation contract, error taxonomy, conformance kit.
backend-commondesign notes — rationale and invariants.
The unified backend is in beta. The summary below is the common
contract — what every engine on the unified path gets, whether
written in Rust directly or plugged in from Python via the PyO3
Worker shim. Per-engine specifics (vLLM sleep/wake, SGLang
diffusion, TRT-LLM custom logits processors, etc.) live in the
Python package README.
Supported today
Lifecycle and runtime:
- Aggregated token-in-token-out inference
- Disaggregated serving (
Aggregated/Prefill/Decode) — KV transfer uses NIXL across all production engines; SGLang exchanges a Dynamo-level bootstrap address, vLLM and TRT-LLM use an engine-internal handshake. The Rust mocker example exercises the same wire format CPU-only - Model registration with discovery and endpoint types
- Request cancellation via in-stream
ctx.is_stopped()polling plus the framework's out-of-bandabort()monitor drain()hook for pre-cleanup work- Typed
DynamoErrorwithErrorType::Backend(BackendError::X) - Graceful shutdown with signal handling and 3-phase distributed-runtime teardown
- Debug-build stream validator and the
testing::run_conformancekit - Engine control plumbing, with per-backend profiling, pause/resume, and supported weight-update controls
Observability:
- Health-check canary via
LLMEngine::health_check_payload()plus the operator override (DYN_HEALTH_CHECK_PAYLOAD/--health-check-payload) - Vendor-registry bridge into the runtime's
/metricsoutput viaLLMEngine::setup_metrics(), plus framework-owned lifecycle gauges (dynamo_component_{cleanup_time_seconds, drain_time_seconds, model_load_time_seconds}) and per-rankdynamo_component_*gauges driven bySnapshotPublisher - KV event publishing via
kv_event_sources()returningKvEventSource::ZmqorKvEventSource::Push - KV-aware routing (DP-rank-aware) — engines advertise their slice
via
EngineConfig::data_parallel_size/data_parallel_start_rank; read the router-forced rank offrequest.routing.dp_rankingenerate() - OpenTelemetry tracing — the framework auto-opens an
engine.generatespan around everygenerate()call with attributes formodel/input_tokens/disagg_role/ttft_ms/output_tokens/finish_reason/ ITL percentiles. Static-name spans opened withtracing::info_span!insidegenerate()nest under it automatically; for dynamic span names usedynamo_backend_common::telemetry::start_span(name). For outbound calls that need to carry trace context (custom HTTP/TCP transports), usedynamo_runtime::logging::inject_trace_headers_into_map. NATS egress is auto-injected — engines do nothing.
Request handling:
- Guided decoding — request shape carries
SamplingOptions::guided_decoding(GuidedDecodingOptions); engine-side coverage on the existing Python-bridged engines is: vLLM and TRT-LLM forward JSON schema / regex / grammar / choice; SGLang forwards JSON schema only (regex / grammar / choice are silently dropped today). A new Rust engine should forward whichever variants its backend supports - Structural tag generation —
WorkerConfig::structural_tag_{mode, scope, schema}(typed enums) - Custom Jinja chat templates —
WorkerConfig::custom_jinja_templateflows toLocalModelBuilder::custom_template_pathand the frontend applies the template at preprocessing time - Tool / reasoning parser configuration on
WorkerConfig(tool_call_parser,reasoning_parser,exclude_tools_when_tool_choice_none)
Not yet on the unified path (common to all engines)
| Feature | What's missing |
|---|---|
cum_log_probs response wire |
Completion-side log_probs / top_logprobs are populated on the unified path for vLLM, SGLang, and TRT-LLM (shared helpers in components/src/dynamo/common/backend/logprobs.py). Prompt-side logprobs ride on the final chunk's LLMEngineOutput.engine_data["prompt_logprobs"] (consumed by prompt_logprobs_from_engine_data in the response builders). cum_log_probs is still not emitted. |
| Text-in-text-out mode | ModelInput::Text is rejected at startup — Tokens only |
| Multimodal | Images / video / embeddings, NIXL embedding transfer, separate encode workers; ENCODE disaggregation role |
| Diffusion | Image (FLUX), video (Wan2.1), LLM diffusion (DLLM) workers; no diffusion engine, MediaOutput, or media scheduling on the unified path |
| LoRA adapters | Dynamic load / unload / list, ModelDeploymentCard publishing, per-adapter serialization |
| Snapshot / checkpoint | CRIU-based engine state save/restore + identity reload |
If you need one of these features today, keep that workload on the existing per-engine entry point until the unified path catches up.
A backend is two things:
- An engine type that implements the
LLMEnginetrait — owns the model, accepts preprocessed token requests, streams output tokens. - A
main.rsentry point — a three-line shim that hands the engine todynamo_backend_common::run, which drives the lifecycle.
The dynamo-backend-common crate handles everything else: signal
handling, model registration with discovery, the serving loop, graceful
shutdown, metrics, cancellation plumbing, and the debug-mode contract
validator.
Engines work directly with PreprocessedRequest and LLMEngineOutput
— the same types used by Dynamo's preprocessing, routing, and frontend.
No Python-shaped translation layer.
construct → start() → generate() / abort() → drain() → cleanup()
| | | | |
parse args start engine, serve requests pre-cleanup release
return return (concurrent) drain resources
engine metadata
- Rust 1.85 or newer (the dynamo workspace is edition 2024). The
toolchain pin in Step 1 locks this in for you; older toolchains will
fail with
feature edition2024 is requireddeep inside the build. - NATS and etcd reachable for end-to-end runs. The dynamo repo's
deploy/docker-compose.ymlbrings up both in one command if you don't already have them running. - Familiarity with
asyncRust,tokio, andclap. The trait usesasync_trait, and the framework expects atokioruntime.
Your backend is a standalone Rust binary crate. It can live in its own repository — the dynamo repo is not required to be your parent workspace. Pick whatever layout you prefer:
my-backend/
├── Cargo.toml
└── src/
├── main.rs
└── engine.rs # (or my_engine.rs — whatever you call it)
cargo new --bin my-backend is the fastest starting point; add
src/engine.rs yourself afterwards.
dynamo-backend-common lives in the
ai-dynamo/dynamo repository and
is not on crates.io. Depend on it via git:
[package]
name = "my-backend"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "my-backend"
path = "src/main.rs"
[dependencies]
# Replace <SHA> with the dynamo commit you want to build against.
# `branch = "main"` works too but moves under you on every rebuild.
dynamo-backend-common = { git = "https://github.com/ai-dynamo/dynamo.git", rev = "<SHA>" }
anyhow = "1"
async-stream = "0.3"
async-trait = "0.1"
clap = { version = "4", features = ["derive", "env"] }
futures = "0.3"
# Must match the version pinned by dynamo-runtime — it relies on
# tokio_unstable runtime metrics that change shape across releases.
tokio = { version = "=1.48.0", features = ["full"] }
tracing = "0.1"
[dev-dependencies]
dynamo-backend-common = { git = "https://github.com/ai-dynamo/dynamo.git", rev = "<SHA>", features = ["testing"] }The testing feature pulls in the conformance kit used in Step 7.
Pick a SHA with:
git ls-remote https://github.com/ai-dynamo/dynamo.git mainNo release tags yet.
dynamo-backend-commonlanded after the last tagged release (v1.1.1), sotag = "v1.1.1"won't resolve the crate. Trackmainor pin to a specific SHA until a release tag ships that includes the crate.
These are easy to miss and surface as confusing compile errors deep
inside dynamo-runtime:
-
tokio_unstablecfg flag.dynamo-runtimeuses tokio's unstable runtime-metrics API. Create.cargo/config.tomlin your crate root:[build] rustflags = ["--cfg", "tokio_unstable"]
Without it, you'll see errors like
method blocking_queue_depth not found on RuntimeMetricswhile compilingdynamo-runtime. -
Rust toolchain pin. Match dynamo's toolchain so workspace-edition crates compile. Create
rust-toolchain.toml:[toolchain] channel = "1.93.1"
Older toolchains fail with
feature edition2024 is required.
Tip — local development: while iterating against an unreleased change in
dynamo-backend-common, point the dep at a local clone:dynamo-backend-common = { path = "/path/to/dynamo/lib/backend-common" }. Switch back to the git dep before publishing your crate.
If you'd rather develop inside the dynamo workspace as a new
sub-crate, drop your crate under dynamo/lib/ and use
dynamo-backend-common = { workspace = true } instead. The trait
contract is identical, and the .cargo/config.toml plus toolchain
pin in the dynamo repo cover the two requirements above for you.
In src/engine.rs (or whatever you named it), declare a struct that
owns whatever state your engine needs. Anything you allocate inside
start() later must live behind interior mutability so the trait's
&self methods can reach it.
use async_trait::async_trait;
use dynamo_backend_common::engine::GenerateContext;
use dynamo_backend_common::{
BackendError, CommonArgs, DynamoError, EngineConfig, ErrorType, FinishReason, LLMEngine,
LLMEngineOutput, LLMEngineOutputExt, LlmRegistration, PreprocessedRequest, WorkerConfig,
chunk, usage,
};
use futures::stream::BoxStream;
use tokio::sync::RwLock;
pub struct MyBackend {
model: String,
inner: RwLock<Option<Inner>>, // allocated in start()
}
// Replace this with whatever your engine owns — handle, scheduler,
// client, channel sender, etc. Fields go here. Truly stateless
// engines can skip `Inner` and `inner` entirely.
struct Inner {}async-trait lets the trait use async fn (still required for
object-safety with Arc<dyn LLMEngine>); async-stream's stream!
macro lets the generate body yield items from inside an async block.
The mocker example uses OnceCell for Inner; RwLock<Option<_>>
also works — pick whichever fits your shutdown semantics.
Every backend's CLI shares a common base (--namespace, --component,
--endpoint, etc.) provided by CommonArgs. Flatten that into your
engine's Args struct and add your engine-specific flags.
#[derive(clap::Parser, Debug)]
#[command(
name = env!("CARGO_BIN_NAME"),
about = "My Dynamo Rust backend."
)]
struct Args {
#[command(flatten)]
common: CommonArgs,
/// HF repo or local model directory.
#[arg(value_name = "MODEL")]
model: String,
/// Public-facing model name advertised to clients.
#[arg(long)]
served_model_name: Option<String>,
// ... engine-specific flags here.
}Define an inherent from_args constructor that parses the args and
returns both the engine and a WorkerConfig. from_args is not on
the trait — it stays inherent so the trait can remain object-safe
(Arc<dyn LLMEngine> must work).
The snippet below calls a tiny invalid_arg helper that builds a
typed BackendError::InvalidArgument. Its full definition lives in
Step 6 — for now, mentally substitute "any function that returns a
DynamoError with category InvalidArgument."
impl MyBackend {
pub fn from_args(argv: Option<Vec<String>>) -> Result<(Self, WorkerConfig), DynamoError> {
let args = match argv {
Some(a) => <Args as clap::Parser>::try_parse_from(a),
None => <Args as clap::Parser>::try_parse(),
}
.map_err(|e| invalid_arg(e.to_string()))?;
let engine = Self {
model: args.model.clone(),
inner: RwLock::new(None),
};
let config = WorkerConfig {
namespace: args.common.namespace,
component: args.common.component,
endpoint: args.common.endpoint,
endpoint_types: args.common.endpoint_types,
custom_jinja_template: args.common.custom_jinja_template,
// Pass `--disaggregation-mode` from `CommonArgs` through to the
// Worker — without this line the worker silently registers as
// Aggregated regardless of what the operator passed.
disaggregation_mode: args.common.disaggregation_mode,
model_name: args.model,
served_model_name: args.served_model_name,
..Default::default()
};
Ok((engine, config))
}
}WorkerConfig::default() sets model_input to ModelInput::Tokens,
which is the only mode Worker currently supports — the framework
validates this at startup. Engines needing raw text or tensor inputs
aren't supported yet.
If your engine branches on the disaggregation role inside generate
(prefill vs decode), keep the same DisaggregationMode on your engine
struct so the runtime registration (WorkerConfig) and the per-request
dispatch stay in lockstep.
The trait has three required methods (start, generate, cleanup)
plus two with default implementations you can override (abort, drain).
Start the engine and return EngineConfig metadata. After this
returns, the engine MUST be ready for concurrent generate() calls.
Use interior mutability for anything you initialize here.
async fn start(&self, _worker_id: u64) -> Result<EngineConfig, DynamoError> {
tracing::info!(model = %self.model, "starting my backend");
// ... start your engine (may take minutes for real backends — emit
// tracing::info! checkpoints so operators see progress).
let inner = init_engine(&self.model).await?;
*self.inner.write().await = Some(inner);
Ok(EngineConfig {
model: self.model.clone(),
served_model_name: Some(self.model.clone()),
// Token-pipeline metadata goes in the `llm` sub-record (RawEngines
// leave it None).
llm: Some(LlmRegistration {
context_length: Some(8192),
kv_cache_block_size: Some(64), // None if no block-structured KV
total_kv_blocks: Some(16384),
max_num_seqs: Some(256),
max_num_batched_tokens: Some(8192),
..Default::default()
}),
..Default::default()
})
}worker_id is an opaque per-worker identifier — most engines ignore
it with _worker_id. Backends needing a stable cluster-wide key
(e.g. TRT-LLM's disagg_machine_id snowflake) should derive from it.
Every EngineConfig field except model is optional. None means
"don't advertise"; KV-aware routing falls back to round-robin when KV
fields are unset. Engines wrapping an external runtime can read these
values from the live engine after it comes up, instead of hard-coding
them. The ..Default::default() is load-bearing: EngineConfig
sometimes grows new fields (e.g. bootstrap_host/bootstrap_port
for SGLang disagg) and the default keeps existing engines compiling.
Yield a stream of Result<LLMEngineOutput, DynamoError> items for a
single request. Called concurrently for multiple in-flight requests.
ctx: GenerateContext is a thin wrapper that Derefs to
dyn AsyncEngineContext, so the cancellation methods (stopped(),
is_stopped(), id()) you'd expect are still there. The wrapper
additionally exposes notify_first_token() for decode-mode requests
— most engines can ignore it; the framework auto-fires on the first
non-empty chunk.
Contract (the debug-mode validator panics on violations):
- Exactly one terminal item must be the last item yielded. A
terminal is either an
Ok(chunk)withfinish_reasonset, or anErr(DynamoError). No items may be yielded after a terminal. - Non-terminal chunks use
chunk::token(id)and leavefinish_reasonunset. - The returned stream is
'static: clone or move any state from&selforrequestinto the stream body before constructing it.
Terminal chunks come from one of four LLMEngineOutput constructors,
optionally chained with the LLMEngineOutputExt setters
(.with_tokens(...), .with_usage(...)):
LLMEngineOutput::stop()— natural completion (e.g. you reached your echo limit, the engine hit a stop string).LLMEngineOutput::length()—max_tokenscap reached.LLMEngineOutput::cancelled()— you observedctx.stopped().LLMEngineOutput::error(msg)— message-only error terminal (loses the typedBackendErrorvariant — yieldErr(DynamoError)instead when the category matters).
Non-terminal chunks use chunk::token(id) (single-token convenience).
A streaming-generate template:
async fn generate(
&self,
request: PreprocessedRequest,
ctx: GenerateContext,
) -> Result<BoxStream<'static, Result<LLMEngineOutput, DynamoError>>, DynamoError> {
// Destructure once and move fields into the stream — no extra clones
// (the stream is 'static and outlives `request`).
let PreprocessedRequest { token_ids, stop_conditions, .. } = request;
let prompt_tokens = token_ids.len() as u32;
let mut output_rx = self.submit_to_engine(token_ids, stop_conditions).await?;
Ok(Box::pin(async_stream::stream! {
let mut completion_tokens = 0_u32;
loop {
tokio::select! {
biased;
// Cancellation: emit FinishReason::Cancelled terminal.
_ = ctx.stopped() => {
yield Ok(LLMEngineOutput::cancelled()
.with_usage(usage(prompt_tokens, completion_tokens)));
break;
}
// Next item from the engine.
next = output_rx.recv() => {
let Some(engine_output) = next else {
yield Ok(LLMEngineOutput::error(
"engine stream ended without a terminal".into()
));
break;
};
// Translate your engine's per-step output into LLMEngineOutput.
// For a terminal step set `finish_reason`; otherwise leave it None.
let mut out = LLMEngineOutput {
token_ids: engine_output.tokens,
finish_reason: engine_output.terminal_reason,
..Default::default()
};
completion_tokens += out.token_ids.len() as u32;
if out.finish_reason.is_some() {
out = out.with_usage(usage(prompt_tokens, completion_tokens));
yield Ok(out);
break;
}
yield Ok(out);
}
}
}
}))
}biased is load-bearing for the channel-receiving pattern above:
- When cancellation and a pending token are both ready, yield the cancellation, not one more token.
- During cleanup the stream sees both
ctx.stopped()andrx.recv() -> Nonesimultaneously;biasedpicks the clean cancellation path instead of erroring on a closed channel. The mocker's stream body spells this out.
If your engine doesn't have a receiver — e.g. you're computing tokens inline like a deterministic echo backend — the body collapses to a plain loop that polls cancellation between yields:
Ok(Box::pin(async_stream::stream! {
for (i, token_id) in tokens_to_emit.iter().enumerate() {
tokio::select! {
biased;
_ = ctx.stopped() => {
yield Ok(LLMEngineOutput::cancelled()
.with_usage(usage(prompt_tokens, i as u32)));
return;
}
_ = tokio::time::sleep(delay), if !delay.is_zero() => {}
}
if i == tokens_to_emit.len() - 1 {
yield Ok(LLMEngineOutput::stop()
.with_tokens(vec![*token_id])
.with_usage(usage(prompt_tokens, (i + 1) as u32)));
} else {
yield Ok(chunk::token(*token_id));
}
}
}))No channel-close race to worry about; biased is still cheap and
recommended for consistency.
Cancellation rules:
- The stream must poll
ctx.is_stopped()(orawait ctx.stopped()) between yields. - On cancellation, emit a terminal with
FinishReason::Cancelled— notLengthorStop. The conformance kit treats any other terminal after cancellation as ignoring the signal.
Typed errors vs. string errors:
// Typed (preferred): preserves BackendError variant end-to-end.
yield Err(DynamoError::builder()
.error_type(ErrorType::Backend(BackendError::InvalidArgument))
.message("bad request")
.build());
// String: convenient, loses the typed variant.
yield Ok(LLMEngineOutput::error("something went wrong".into()));Use typed errors when the failure category matters to the caller. Use string errors when it doesn't.
abort is called by the framework only when ctx.stopped() or
ctx.killed() fires — i.e. an explicit client/operator cancel. It is
NOT called when the stream is silently dropped (TCP reset, consumer
timeout without cancellation).
For cleanup that must run on any drop path (releasing a scheduler
slot, freeing a request handle), use RAII inside the generate stream
body:
struct RequestGuard { /* ... */ }
impl Drop for RequestGuard {
fn drop(&mut self) {
// Always runs when the stream is dropped, however that happens.
}
}
Ok(Box::pin(async_stream::stream! {
let _guard = RequestGuard { /* ... */ };
// ... your stream body
}))The mocker's ActiveRequestGuard is the canonical example.
Use abort only for out-of-band notifications (e.g. telling a remote
scheduler to stop computing for this request).
drain()runs once before shutdown, after the discovery unregister + grace-period sleep, while NATS/etcd are still alive. Use it for backend-side draining that must complete before the transport layer goes away (e.g. in-flight NIXL KV transfers on prefill workers). Default is no-op.cleanup()is called once on shutdown. Release all engine resources. The framework guaranteescleanup()runs exactly once ifstart()succeeded — even if registration or serve fails afterward.
Make cleanup() idempotent and tolerant of being called from a
half-initialized state. Engines like vLLM/TRT-LLM tear down NCCL groups
in cleanup() and a second attempt can hang.
Three lines. That's it.
use std::sync::Arc;
mod engine;
fn main() -> anyhow::Result<()> {
let (engine, config) = engine::MyBackend::from_args(None)?;
dynamo_backend_common::run(Arc::new(engine), config)
}run installs signal handlers, builds the distributed runtime, calls
engine.start(), registers the model with discovery, serves the
endpoint, and runs the full graceful-shutdown orchestrator on
SIGTERM/SIGINT.
Errors: every error returned from start, generate, cleanup,
and from_args uses ErrorType::Backend(BackendError::X). From the
frontend's perspective, anything bubbling up through the backend has
"originated from the backend" — engine code vs. framework code is not
distinguished. Top-level ErrorType::X variants are reserved for
non-backend paths.
A small helper module per backend keeps the call sites clean:
pub(crate) fn invalid_arg(msg: impl Into<String>) -> DynamoError {
DynamoError::builder()
.error_type(ErrorType::Backend(BackendError::InvalidArgument))
.message(msg)
.build()
}Common nested categories: InvalidArgument, CannotConnect,
EngineShutdown, StreamIncomplete, Cancelled, ResponseTimeout,
Disconnected, ConnectionTimeout, Unknown.
Logging: keep levels consistent across Rust backends so operators see the same surface everywhere.
tracing::info!for lifecycle milestones (engine started, cleanup complete).Workeralready logs "Serving {model} on …" and "Engine cleanup complete" — add your own only for events those don't cover.tracing::debug!for per-request events (cancellation, abort).tracing::warn!for recoverable problems.tracing::error!only for unrecoverable failures.
Before merging, prove your engine satisfies the contract. The conformance kit is one call:
#[tokio::test]
async fn my_engine_passes_conformance() {
// `run_conformance` takes a factory closure rather than a built
// engine — the kit constructs a second pristine engine for its
// "cleanup-without-start" check.
dynamo_backend_common::testing::run_conformance(|| {
MyBackend::new(/* your defaults */).expect("construct")
})
.await
.expect("conformance");
}The kit runs start/generate/cleanup directly against your engine
— no external service is involved. If your engine needs a real GPU,
remote model server, or other heavyweight resource to construct, gate
the test with #[ignore] and require an explicit opt-in env var.
What it asserts:
| Check | Failure mode |
|---|---|
start() returns a non-empty EngineConfig.model |
EmptyModelInConfig |
Single generate() ends in a terminal chunk |
NoTerminalChunk |
| No chunks after the terminal | ChunkAfterTerminal |
Interleaved generate() calls all succeed |
ConcurrentGenerateFailed |
| Mid-stream cancel terminates within 2s | CancellationNotObserved |
Cancelled stream's terminal is FinishReason::Cancelled |
CancellationIgnored |
cleanup() succeeds twice (idempotent) |
SecondCleanupFailed |
cleanup() on a never-started engine succeeds |
CleanupWithoutStartFailed |
For tests that don't need a real engine, use testing::mock_context()
or testing::cancelling_context(after) to drive generate manually.
Three moving parts need to come up: NATS + etcd (discovery and the event/request planes), the Dynamo Python frontend (HTTP → backend discovery), and your backend.
The fastest path is to copy the mocker example's
docker-compose.yml
and Dockerfile.frontend,
swap in your image, and run docker compose up --build. That brings
up NATS + etcd + the Python frontend (built from the dynamo workspace
at the same SHA as your backend) + your backend, all on one network.
For a non-Docker dev loop:
cargo build --release
# Ensure NATS + etcd are reachable (NATS_SERVER, ETCD_ENDPOINTS).
./target/release/my-backend Qwen/Qwen3-0.6B \
--namespace dynamo \
--component backend \
--endpoint generate
# In another shell, start the Python frontend from the dynamo repo:
python -m dynamo.frontend --http-port 8000Then send a request:
curl http://localhost:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "Qwen/Qwen3-0.6B",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 32
}'A successful response has non-empty choices[0].message.content and a
finish_reason of stop or length. jq -e '.choices[0].finish_reason'
is a good one-liner for a CI smoke test.
run initializes tracing from the DYN_LOG env var (defaults to
info); set DYN_LOG=debug or
DYN_LOG=info,dynamo_backend_common=trace for more detail. RUST_LOG
is not honored — DYN_LOG replaces it.
lib/backend-common/examples/mocker
is the canonical small-but-complete reference. Lift these patterns:
- Single shared scheduler driving many concurrent streams via a
fan-out task and per-request
mpscchannels. ActiveRequestGuardfor RAII cleanup that runs on any stream drop.biasedselect withctx.stopped()first, channel second — the shutdown-race fix discussed in Step 4.cleanup()signals every active stream viactx.stop_generating()so each yields a cleanCancelledterminal instead of an error from channel-close.
Before shipping:
-
LLMEngineimplemented;from_argsis inherent (not on the trait). - All errors use
ErrorType::Backend(BackendError::X). -
generatepollsctx.is_stopped()between yields and emitsFinishReason::Cancelledon cancel. - Per-request cleanup uses RAII guards, not just
abort. -
cleanupis idempotent. - Conformance kit runs green:
testing::run_conformance(|| ...). - Logging levels match the standards in Step 6.
- Crate README — in-tree reference (architecture, file index, contracts at a glance).
LLMEnginetrait — authoritative contract.- Design notes — rationale and invariants.
Worker— runtime lifecycle internals (signal handling, graceful shutdown, model registration).- Conformance kit —
run_conformance,mock_context,cancelling_context. - Mocker backend — example user guide.
- Python sibling — Python ABC layered over this crate.