Чтобы получить чистое Markdown-содержимое этой страницы, добавьте .md к этому URL. Полный индекс документации см. на https://docs.nvidia.com/dynamo/llms.txt. Полное содержимое, включая справочник API и примеры SDK, см. на https://docs.nvidia.com/dynamo/llms-full.txt.
Написание унифицированного бэкенда на Rust
Написание унифицированного бэкенда на Rust
Новое — унифицированный бэкенд Dynamo. В этом руководстве описана новая инфраструктура унифицированного бэкенда в
dynamo-backend-common: общий контрактLLMEngine, который уже реализуют vLLM, SGLang, TRT-LLM и mocker и к которому любой пользовательский движок может подключиться тем же способом.Бета — активно разрабатывается. Поверхность нативного Rust-бэкенда находится в бета-состоянии и может меняться между релизами без обратной совместимости. Ниже в разделе Пробелы в функциях описано, что сегодня покрывает унифицированный путь по сравнению с существующими (неунифицированными) путями бэкендов.
В этом руководстве показано, как создать унифицированный бэкенд на Rust для
движка инференса, который подключается к распределенной среде выполнения Dynamo.
Унифицированный бэкенд — это самостоятельный бинарный файл Rust, который владеет
своим движком и обслуживает запросы через общий контракт
LLMEngine в
dynamo-backend-common — среда
выполнения Python worker не требуется. Версию того же контракта для Python см. в
Написание унифицированного бэкенда на Python.
Ваш бэкенд живет в собственном crate и не обязан быть частью репозитория
dynamo. Он подключает dynamo-backend-common как обычную зависимость git или
path. Шаги ниже предполагают, что вы начинаете с нового crate в собственном
репозитории; необязательное примечание в шаге 1 описывает вариант внутри дерева
для контрибьюторов, которые добавляют бэкенд в ai-dynamo/dynamo.
Для движка на Python используйте Написание унифицированного бэкенда на Python — тот же контракт, но более легкая настройка. Неунифицированный запасной путь для функциональных пробелов (multimodal, LoRA, logprobs и т. д.) доступен только в Python; см. Написание Python workers, если сегодня вам нужна одна из этих возможностей.
Эталонный пример — бэкенд mocker в
lib/backend-common/examples/mocker
— небольшая, полная, чистая реализация на Rust. Читайте ее параллельно с этим
руководством.
Где что искать:
- Это руководство — пошаговый разбор для тех, кто начинает новый бэкенд с нуля.
- Комментарии документации trait
LLMEngine— авторитетный контракт по каждому методу. - README crate — справочник внутри дерева: архитектура, индекс файлов, контракт disaggregation, таксономия ошибок, набор проверок соответствия.
- Заметки по дизайну
backend-common— обоснование и инварианты.
Пробелы в функциях
Унифицированный бэкенд находится в бета-версии. Сводка ниже описывает общий
контракт — то, что получает каждый движок на унифицированном пути, независимо от
того, написан ли он напрямую на Rust или подключен из Python через shim PyO3
Worker. Особенности отдельных движков (sleep/wake в vLLM, diffusion в SGLang,
пользовательские logits processors в TRT-LLM и т. д.) описаны в
README Python-пакета.
Поддерживается сегодня
Жизненный цикл и среда выполнения:
- Агрегированный инференс token-in-token-out
- Разделенное обслуживание (
Aggregated/Prefill/Decode) — передача KV использует NIXL во всех production-движках; SGLang обменивается bootstrap-адресом уровня Dynamo, vLLM и TRT-LLM используют внутреннее для движка рукопожатие. Rust-пример mocker проверяет тот же wire format только на CPU - Регистрация модели с discovery и типами endpoint
- Отмена запроса через опрос
ctx.is_stopped()внутри stream плюс внеполосный мониторabort()фреймворка - Хук
drain()для работы перед cleanup - Типизированный
DynamoErrorсErrorType::Backend(BackendError::X) - Graceful shutdown с обработкой сигналов и трехфазным разбором распределенной среды выполнения
- Валидатор stream для debug-build и набор
testing::run_conformance
Наблюдаемость:
- Canary для health-check через
LLMEngine::health_check_payload()плюс переопределение оператором (DYN_HEALTH_CHECK_PAYLOAD/--health-check-payload) - Мост vendor-registry в вывод
/metricsсреды выполнения черезLLMEngine::setup_metrics(), а также gauges жизненного цикла, которыми владеет фреймворк (dynamo_component_{cleanup_time_seconds, drain_time_seconds, model_load_time_seconds}), и per-rank gaugesdynamo_component_*, которыми управляетSnapshotPublisher - Публикация событий KV через
kv_event_sources(), возвращающийKvEventSource::ZmqилиKvEventSource::Push - KV-aware routing (с учетом DP-rank) — движки объявляют свой срез через
EngineConfig::data_parallel_size/data_parallel_start_rank; rank, принудительно заданный router, читайте изrequest.routing.dp_rankвgenerate() - Трассировка OpenTelemetry — фреймворк автоматически открывает span
engine.generateвокруг каждого вызоваgenerate()с атрибутами дляmodel/input_tokens/disagg_role/ttft_ms/output_tokens/finish_reason/ процентилей ITL. Span со статическими именами, открытые черезtracing::info_span!внутриgenerate(), автоматически вкладываются в него; для динамических имен span используйтеdynamo_backend_common::telemetry::start_span(name). Для исходящих вызовов, которым нужно переносить trace context (пользовательские HTTP/TCP-транспорты), используйтеdynamo_runtime::logging::inject_trace_headers_into_map. NATS egress получает автоматическую инъекцию — движкам ничего делать не нужно.
Обработка запросов:
- Guided decoding — форма запроса несет
SamplingOptions::guided_decoding(GuidedDecodingOptions); покрытие на стороне движка в существующих движках, подключенных через Python bridge: vLLM и TRT-LLM пробрасывают JSON schema / regex / grammar / choice; SGLang пробрасывает только JSON schema (regex / grammar / choice сегодня тихо отбрасываются). Новый Rust-движок должен пробрасывать те варианты, которые поддерживает его бэкенд - Генерация структурных тегов —
WorkerConfig::structural_tag_{mode, scope, schema}(типизированные enum) - Пользовательские chat templates Jinja —
WorkerConfig::custom_jinja_templateпопадает вLocalModelBuilder::custom_template_path, а frontend применяет template на этапе preprocessing - Конфигурация parser для tool / reasoning в
WorkerConfig(tool_call_parser,reasoning_parser,exclude_tools_when_tool_choice_none)
Пока не на унифицированном пути (общее для всех движков)
| Функция | Чего не хватает |
|---|---|
| Logprob response wire | PreprocessedRequest.output_options.{logprobs, prompt_logprobs} существует в форме запроса. Из существующих движков (подключенных из Python через PyO3) только vLLM пробрасывает эту опцию в свои sampling params на унифицированном пути; унифицированные generate() в SGLang и TRT-LLM ее игнорируют. Ни один движок не заполняет log_probs / top_logprobs / cum_log_probs в LLMEngineOutput — response wire открыт, но не используется |
| Режим text-in-text-out | ModelInput::Text отклоняется при запуске — только Tokens |
| Multimodal | Изображения / видео / embeddings, передача embeddings через NIXL, отдельные encode workers; роль disaggregation ENCODE |
| Diffusion | Workers для изображений (FLUX), видео (Wan2.1), LLM diffusion (DLLM); на унифицированном пути нет diffusion engine, MediaOutput или планирования media |
| Адаптеры LoRA | Динамическая загрузка / выгрузка / список, публикация ModelDeploymentCard, сериализация по адаптерам |
| Маршруты движка | Profile start/stop, sleep / wake / quiesce, обновления весов (disk / tensor / distributed / IPC), очистка KV block, сброс prefix cache |
| Snapshot / checkpoint | Сохранение/восстановление состояния движка на базе CRIU + перезагрузка identity |
Если сегодня вам нужна одна из этих функций, оставьте такую нагрузку на существующей per-engine точке входа, пока унифицированный путь не догонит ее.
Что вы создаете
Бэкенд состоит из двух частей:
- Тип движка, который реализует trait
LLMEngine: владеет моделью, принимает предварительно обработанные token requests и стримит output tokens. - Точка входа
main.rs— shim из трех строк, который передает движок вdynamo_backend_common::run, управляющий жизненным циклом.
Crate dynamo-backend-common берет на себя все остальное: обработку сигналов,
регистрацию модели с discovery, serving loop, graceful shutdown, метрики,
механику отмены и валидатор контракта в debug-mode.
Движки работают напрямую с PreprocessedRequest и LLMEngineOutput — теми же
типами, которые используют preprocessing, routing и frontend Dynamo. Слоя
перевода под Python-форму нет.
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 или новее (workspace dynamo использует edition 2024). Pin toolchain
в шаге 1 фиксирует это за вас; более старые toolchain завершатся ошибкой
feature edition2024 is requiredглубоко внутри сборки. - NATS и etcd должны быть доступны для end-to-end запусков. В репозитории dynamo
deploy/docker-compose.ymlподнимает оба сервиса одной командой, если они у вас еще не запущены. - Знание
asyncRust,tokioиclap. Trait используетasync_trait, а фреймворк ожидает среду выполненияtokio.
Шаг 1. Создайте crate
Ваш бэкенд — самостоятельный бинарный crate Rust. Он может жить в собственном репозитории — репозиторий dynamo не обязан быть родительским workspace. Выберите любую удобную структуру:
my-backend/
├── Cargo.toml
└── src/
├── main.rs
└── engine.rs # (or my_engine.rs — whatever you call it)
cargo new --bin my-backend — самый быстрый старт; после этого добавьте
src/engine.rs самостоятельно.
Получение crate dynamo-backend-common
dynamo-backend-common находится в репозитории
ai-dynamo/dynamo и не опубликован на
crates.io. Подключайте его через 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"] }
Функция testing подтягивает набор проверок соответствия, который используется
в шаге 7.
Выберите SHA с помощью:
git ls-remote https://github.com/ai-dynamo/dynamo.git main
Тегов релизов пока нет.
dynamo-backend-commonпоявился после последнего tagged release (v1.1.1), поэтомуtag = "v1.1.1"не сможет разрешить этот crate. Отслеживайтеmainили фиксируйтесь на конкретном SHA, пока не выйдет release tag, включающий crate.
Два обязательных требования на этапе сборки
Их легко пропустить, а проявляются они как запутанные ошибки компиляции глубоко
внутри dynamo-runtime:
-
Флаг cfg
tokio_unstable.dynamo-runtimeиспользует нестабильный API runtime-metrics из tokio. Создайте.cargo/config.tomlв корне вашего crate:[build]rustflags = ["--cfg", "tokio_unstable"]Без него при компиляции
dynamo-runtimeвы увидите ошибки вродеmethod blocking_queue_depth not found on RuntimeMetrics. -
Pin Rust toolchain. Используйте toolchain dynamo, чтобы crate с workspace-edition компилировались. Создайте
rust-toolchain.toml:[toolchain]channel = "1.93.1"Более старые toolchain падают с
feature edition2024 is required.
Совет — локальная разработка: пока вы итерируетесь над еще не выпущенным изменением в
dynamo-backend-common, укажите зависимость на локальный clone:dynamo-backend-common = { path = "/path/to/dynamo/lib/backend-common" }. Перед публикацией вашего crate вернитесь к git-зависимости.
Если вы предпочитаете разрабатывать внутри workspace dynamo как новый
sub-crate, поместите crate в dynamo/lib/ и используйте
dynamo-backend-common = { workspace = true }. Контракт trait идентичен, а
.cargo/config.toml и pin toolchain в репозитории dynamo уже покрывают два
требования выше.
Шаг 2. Определите struct вашего движка
В src/engine.rs (или файле с выбранным вами именем) объявите struct, который
владеет всем состоянием, необходимым вашему движку. Все, что вы позже выделяете
внутри start(), должно жить за interior mutability, чтобы методы trait с
&self могли до этого добраться.
use async_trait::async_trait;
use dynamo_backend_common::engine::GenerateContext;
use dynamo_backend_common::{
BackendError, CommonArgs, DynamoError, EngineConfig, ErrorType, FinishReason, LLMEngine,
LLMEngineOutput, LLMEngineOutputExt, 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 позволяет trait использовать async fn (это все еще требуется
для object-safety с Arc<dyn LLMEngine>); макрос stream! из async-stream
позволяет телу generate выдавать элементы изнутри блока async.
Пример mocker использует OnceCell для Inner; RwLock<Option<_>> тоже
работает — выберите вариант, который лучше подходит вашей семантике shutdown.
Шаг 3. Подключите аргументы CLI
CLI каждого бэкенда использует общую базу (--namespace, --component,
--endpoint и т. д.), которую предоставляет CommonArgs. Встройте ее в struct
Args вашего движка и добавьте флаги, специфичные для движка.
#[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.
}
Определите inherent-конструктор from_args, который разбирает аргументы и
возвращает движок вместе с WorkerConfig. from_args не является частью
trait — он остается inherent, чтобы trait мог оставаться object-safe
(Arc<dyn LLMEngine> должен работать).
Фрагмент ниже вызывает небольшой helper invalid_arg, который строит
типизированный BackendError::InvalidArgument. Полное определение находится в
шаге 6 — пока мысленно подставьте «любую функцию, возвращающую DynamoError с
категорией 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() задает model_input как ModelInput::Tokens, и это
единственный режим, который сейчас поддерживает Worker; фреймворк проверяет
это при запуске. Движки, которым нужен raw text или tensor inputs, пока не
поддерживаются.
Если ваш движок ветвится по роли disaggregation внутри generate (prefill vs
decode), храните тот же DisaggregationMode в struct движка, чтобы регистрация
runtime (WorkerConfig) и dispatch по каждому запросу оставались согласованы.
Шаг 4. Реализуйте trait LLMEngine
У trait есть три обязательных метода (start, generate, cleanup) и два
метода с реализациями по умолчанию, которые можно переопределить (abort,
drain).
start()
Запустите движок и верните метаданные EngineConfig. После возврата из этого
метода движок ДОЛЖЕН быть готов к конкурентным вызовам generate(). Для всего,
что инициализируется здесь, используйте interior mutability.
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()),
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()
})
}
worker_id — непрозрачный идентификатор на worker; большинство движков
игнорируют его как _worker_id. Бэкенды, которым нужен стабильный ключ на весь
кластер (например, snowflake disagg_machine_id в TRT-LLM), должны выводить его
из этого значения.
Все поля EngineConfig, кроме model, необязательны. None означает «не
объявлять»; KV-aware routing откатывается к round-robin, когда KV-поля не
заданы. Движки, оборачивающие внешнюю runtime, могут читать эти значения из
живого движка после его запуска, а не хардкодить их. ..Default::default()
важен: в EngineConfig иногда появляются новые поля (например,
bootstrap_host/bootstrap_port для SGLang disagg), и default сохраняет
компилируемость существующих движков.
generate()
Выдайте stream элементов Result<LLMEngineOutput, DynamoError> для одного
запроса. Метод вызывается конкурентно для нескольких in-flight requests.
ctx: GenerateContext — тонкая обертка, которая Deref-ится в
dyn AsyncEngineContext, поэтому ожидаемые методы отмены (stopped(),
is_stopped(), id()) остаются доступными. Обертка дополнительно предоставляет
notify_first_token() для запросов в decode-mode — большинство движков может
это игнорировать; фреймворк автоматически срабатывает на первом непустом chunk.
Контракт (валидатор debug-mode вызывает panic при нарушениях):
- Ровно один terminal item должен быть последним выданным элементом.
Terminal — это либо
Ok(chunk)с заданнымfinish_reason, либоErr(DynamoError). После terminal нельзя выдавать никакие элементы. - Нетерминальные chunks используют
chunk::token(id)и оставляютfinish_reasonнезаданным. - Возвращаемый stream имеет
'static: перед его созданием склонируйте или переместите любое состояние из&selfилиrequestв тело stream.
Terminal chunks создаются одним из четырех конструкторов LLMEngineOutput,
которые при необходимости можно сцепить с setters из LLMEngineOutputExt
(.with_tokens(...), .with_usage(...)):
LLMEngineOutput::stop()— естественное завершение (например, достигнут echo limit или движок встретил stop string).LLMEngineOutput::length()— достигнут лимитmax_tokens.LLMEngineOutput::cancelled()— вы обнаружилиctx.stopped().LLMEngineOutput::error(msg)— error terminal только с сообщением (теряет типизированный вариантBackendError; если категория важна, выдавайтеErr(DynamoError)).
Нетерминальные chunks используют chunk::token(id) (удобная форма для одного
token).
Шаблон streaming-generate:
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 важен для приведенного выше паттерна приема из channel:
- Когда одновременно готовы cancellation и pending token, выдайте cancellation, а не еще один token.
- Во время cleanup stream одновременно видит
ctx.stopped()иrx.recv() -> None;biasedвыбирает чистый путь cancellation вместо ошибки на закрытом channel. В теле stream mocker это расписано явно.
Если у вашего движка нет receiver — например, вы вычисляете tokens inline, как детерминированный echo backend, — тело сводится к простому циклу, который проверяет cancellation между yield:
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));
}
}
}))
О гонке закрытия channel беспокоиться не нужно; biased все равно дешев и
рекомендуется для единообразия.
Правила cancellation:
- Stream должен опрашивать
ctx.is_stopped()(или выполнятьawait ctx.stopped()) между yield. - При cancellation выдавайте terminal с
FinishReason::Cancelled, а неLengthилиStop. Набор conformance считает любой другой terminal после cancellation игнорированием сигнала.
Типизированные ошибки против строковых ошибок:
// 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()));
Используйте типизированные ошибки, когда категория сбоя важна вызывающей стороне. Используйте строковые ошибки, когда это не важно.
abort() и cleanup на каждый запрос
abort вызывается фреймворком только когда срабатывает ctx.stopped() или
ctx.killed(), то есть при явной отмене клиентом/оператором. Он НЕ вызывается,
когда stream молча сбрасывается (TCP reset, consumer timeout без cancellation).
Для cleanup, который должен выполняться при любом drop path (освобождение
scheduler slot, освобождение request handle), используйте RAII внутри тела
stream generate:
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
}))
ActiveRequestGuard из mocker — канонический пример.
Используйте abort только для внеполосных уведомлений (например, чтобы сказать
удаленному scheduler остановить вычисления для этого запроса).
drain() and cleanup()
drain()запускается один раз перед shutdown, после discovery unregister + sleep на grace period, пока NATS/etcd еще живы. Используйте его для draining на стороне бэкенда, который должен завершиться до исчезновения транспортного слоя (например, in-flight передачи NIXL KV на prefill workers). По умолчанию это no-op.cleanup()вызывается один раз при shutdown. Освободите все ресурсы движка. Фреймворк гарантирует, чтоcleanup()выполнится ровно один раз, еслиstart()успешно завершился, даже если после этого упали registration или serve.
Сделайте cleanup() идемпотентным и устойчивым к вызову из
полуинициализированного состояния. Движки вроде vLLM/TRT-LLM разбирают группы
NCCL в cleanup(), и повторная попытка может зависнуть.
Шаг 5. Напишите main.rs
Три строки. На этом все.
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 устанавливает обработчики сигналов, строит distributed runtime, вызывает
engine.start(), регистрирует модель в discovery, обслуживает endpoint и при
SIGTERM/SIGINT запускает полный orchestrator graceful-shutdown.
Шаг 6. Ошибки и логирование
Ошибки: каждая ошибка, возвращаемая из start, generate, cleanup и
from_args, использует ErrorType::Backend(BackendError::X). С точки зрения
frontend все, что всплывает через бэкенд, «originated from the backend» — код
движка и код фреймворка не различаются. Варианты верхнего уровня
ErrorType::X зарезервированы для путей вне бэкенда.
Небольшой helper module на каждый бэкенд сохраняет call sites чистыми:
pub(crate) fn invalid_arg(msg: impl Into<String>) -> DynamoError {
DynamoError::builder()
.error_type(ErrorType::Backend(BackendError::InvalidArgument))
.message(msg)
.build()
}
Частые вложенные категории: InvalidArgument, CannotConnect,
EngineShutdown, StreamIncomplete, Cancelled, ResponseTimeout,
Disconnected, ConnectionTimeout, Unknown.
Логирование: поддерживайте единые уровни во всех Rust-бэкендах, чтобы операторы везде видели одинаковую поверхность.
tracing::info!для milestone жизненного цикла (движок запущен, cleanup завершен).Workerуже логирует "Serving {model} on …" и "Engine cleanup complete" — добавляйте свои сообщения только для событий, которые они не покрывают.tracing::debug!для событий на каждый запрос (cancellation, abort).tracing::warn!для восстановимых проблем.tracing::error!только для невосстановимых сбоев.
Шаг 7. Запустите набор conformance
Перед merge докажите, что ваш движок удовлетворяет контракту. Набор conformance запускается одним вызовом:
#[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");
}
Набор запускает start/generate/cleanup напрямую против вашего движка —
внешний сервис не участвует. Если для создания вашему движку нужен настоящий
GPU, удаленный model server или другой тяжеловесный ресурс, закройте тест
#[ignore] и требуйте явную opt-in env var.
Что он проверяет:
| Проверка | Режим отказа |
|---|---|
start() возвращает непустой EngineConfig.model | EmptyModelInConfig |
Один generate() завершается terminal chunk | NoTerminalChunk |
| После terminal нет chunks | ChunkAfterTerminal |
Перемежающиеся вызовы generate() все успешны | ConcurrentGenerateFailed |
| Mid-stream cancel завершается в течение 2s | CancellationNotObserved |
Terminal отмененного stream равен FinishReason::Cancelled | CancellationIgnored |
cleanup() успешно выполняется дважды (идемпотентен) | SecondCleanupFailed |
cleanup() на никогда не запускавшемся движке успешен | CleanupWithoutStartFailed |
Для тестов, которым не нужен настоящий движок, используйте
testing::mock_context() или testing::cancelling_context(after), чтобы
управлять generate вручную.
Шаг 8. Запустите локально
Нужно поднять три движущиеся части: NATS + etcd (discovery и planes event/request), Dynamo Python frontend (HTTP → backend discovery) и ваш бэкенд.
Самый быстрый путь — скопировать из примера mocker
docker-compose.yml
и Dockerfile.frontend,
подставить ваш image и выполнить docker compose up --build. Это поднимет NATS
- etcd + Python frontend (собранный из workspace dynamo на том же SHA, что и ваш бэкенд) + ваш бэкенд в одной сети.
Для dev loop без Docker:
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 8000
Затем отправьте запрос:
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
}'
У успешного ответа непустой choices[0].message.content и finish_reason равен
stop или length. jq -e '.choices[0].finish_reason' — хороший one-liner для
CI smoke test.
run инициализирует tracing из env var DYN_LOG (по умолчанию info);
задайте DYN_LOG=debug или DYN_LOG=info,dynamo_backend_common=trace, чтобы
получить больше деталей. RUST_LOG не учитывается — его заменяет DYN_LOG.
Справочник: бэкенд mocker
lib/backend-common/examples/mocker
— канонический небольшой, но полный справочник. Берите из него эти паттерны:
- Один общий scheduler, управляющий множеством конкурентных streams через
fan-out task и per-request channels
mpsc. ActiveRequestGuardдля RAII cleanup, который выполняется при любом drop stream.biasedselect: сначалаctx.stopped(), затем channel — исправление shutdown-race, обсуждавшееся в шаге 4.cleanup()сигналит каждому active stream черезctx.stop_generating(), чтобы каждый выдал чистый terminalCancelled, а не ошибку от закрытия channel.
Чеклист
Перед поставкой:
-
LLMEngineреализован;from_argsявляется inherent (не частью trait). - Все ошибки используют
ErrorType::Backend(BackendError::X). -
generateопрашиваетctx.is_stopped()между yield и при cancel выдаетFinishReason::Cancelled. - Per-request cleanup использует RAII guards, а не только
abort. -
cleanupидемпотентен. - Набор conformance проходит успешно:
testing::run_conformance(|| ...). - Уровни логирования соответствуют стандартам из шага 6.
См. также
- README crate — in-tree справочник (архитектура, индекс файлов, краткий обзор контрактов).
- Trait
LLMEngine— авторитетный контракт. - Заметки по дизайну — обоснование и инварианты.
Worker— внутреннее устройство жизненного цикла runtime (обработка сигналов, graceful shutdown, регистрация модели).- Набор conformance —
run_conformance,mock_context,cancelling_context. - Бэкенд mocker — пример пользовательского руководства.
- Python-аналог — Python ABC, построенный поверх этого crate.