Перейти к основному содержимому

Чтобы получить чистое 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 gauges dynamo_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 wirePreprocessedRequest.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-outModelInput::Text отклоняется при запуске — только Tokens
MultimodalИзображения / видео / embeddings, передача embeddings через NIXL, отдельные encode workers; роль disaggregation ENCODE
DiffusionWorkers для изображений (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 точке входа, пока унифицированный путь не догонит ее.

Что вы создаете

Бэкенд состоит из двух частей:

  1. Тип движка, который реализует trait LLMEngine: владеет моделью, принимает предварительно обработанные token requests и стримит output tokens.
  2. Точка входа 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 поднимает оба сервиса одной командой, если они у вас еще не запущены.
  • Знание async Rust, 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:

  1. Флаг 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.

  2. 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:

  1. Когда одновременно готовы cancellation и pending token, выдайте cancellation, а не еще один token.
  2. Во время 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.modelEmptyModelInConfig
Один generate() завершается terminal chunkNoTerminalChunk
После terminal нет chunksChunkAfterTerminal
Перемежающиеся вызовы generate() все успешныConcurrentGenerateFailed
Mid-stream cancel завершается в течение 2sCancellationNotObserved
Terminal отмененного stream равен FinishReason::CancelledCancellationIgnored
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.
  • biased select: сначала ctx.stopped(), затем channel — исправление shutdown-race, обсуждавшееся в шаге 4.
  • cleanup() сигналит каждому active stream через ctx.stop_generating(), чтобы каждый выдал чистый terminal Cancelled, а не ошибку от закрытия 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, регистрация модели).
  • Набор conformancerun_conformance, mock_context, cancelling_context.
  • Бэкенд mocker — пример пользовательского руководства.
  • Python-аналог — Python ABC, построенный поверх этого crate.