Servindo Modelos com Axum
Inferência de ML em produção via HTTP com Axum 0.8 - estado de modelo compartilhado, requisições validadas e execução não bloqueante.
Receita
use axum::{extract::State, routing::post, Json, Router};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::task;
#[derive(Clone)]
struct AppState {
// session: Arc<ort::session::Session>,
}
#[derive(Deserialize)]
struct PredictRequest {
features: Vec<f32>,
}
#[derive(Serialize)]
struct PredictResponse {
score: f32,
}
async fn predict(
State(_state): State<AppState>,
Json(req): Json<PredictRequest>,
) -> Json<PredictResponse> {
let features = req.features;
let score = task::spawn_blocking(move || run_model(&features))
.await
.unwrap();
Json(PredictResponse { score })
}
fn run_model(features: &[f32]) -> f32 {
features.iter().sum::<f32>() / features.len() as f32
}Quando usar isso:
- Microserviços internos expondo modelos ONNX/candle
- Camada BFF normalizando autenticação e limites de taxa antes da inferência
- RAG Colocado: incorporar + recuperar + gerar atrás de um único binário Rust
- Substituindo sidecars Flask/FastAPI para latência e segurança de memória
Exemplo de Trabalho
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Instant;
use tokio::task;
use tracing::info;
#[derive(Clone)]
struct AppState {
model_version: Arc<String>,
}
#[derive(Deserialize)]
struct PredictRequest {
features: Vec<f32>,
}
#[derive(Serialize)]
struct PredictResponse {
score: f32,
model_version: String,
}
async fn predict(
State(state): State<AppState>,
Json(req): Json<PredictRequest>,
) -> Result<Json<PredictResponse>, StatusCode> {
if req.features.is_empty() || req.features.len() > 10_000 {
return Err(StatusCode::BAD_REQUEST);
}
let version = state.model_version.clone();
let start = Instant::now();
let features = req.features;
let score = task::spawn_blocking(move || infer(&features))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
info!(elapsed_ms = start.elapsed().as_millis(), "inference");
Ok(Json(PredictResponse {
score,
model_version: (*version).clone(),
}))
}
fn infer(features: &[f32]) -> f32 {
features.iter().copied().fold(0.0f32, f32::max)
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let state = AppState {
model_version: Arc::new("v1.0.0".into()),
};
let app = Router::new()
.route("/predict", post(predict))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
axum::serve(listener, app).await.unwrap();
}O que isso demonstra:
- Validação de requisição antes de iniciar a computação
spawn_blockingpara inferência síncrona- String de versão na resposta para cache busting do cliente
tracingpara medir o tempo por requisição
Mergulho Profundo
Como Funciona
- Handlers do Axum são assíncronos; passes de inferência de ML são síncronos e pesados em CPU/GPU.
AppStatecontémArc<Session>ou pesos de modelo carregados na inicialização.- Middleware Tower adiciona timeouts, compressão, autenticação antes dos handlers.
- Rotas de Health/Readiness verificam a presença do arquivo do modelo e uma inferência de teste.
Pilha de Middleware de Produção
| Camada | Propósito |
|---|---|
TimeoutLayer | Encerrar inferências travadas |
RequestBodyLimitLayer | Limitar payloads JSON enormes |
TraceLayer | Spans em nível HTTP |
Armadilhas
- Inferência dentro do async sem pool de bloqueio - trava todas as conexões. Correção: sempre use
spawn_blockingou um pool de threads dedicado. - Recarregar modelo por requisição - segundos de latência de carregamento. Correção: troque
Arca quente com um canalwatchno SIGHUP ou um observador de arquivos. - Sem autenticação em
/predict- queima de GPU aberta. Correção: chaves de API ou mTLS no gateway. - Retornar erros brutos para clientes - vaza caminhos e formatos. Correção: mapeie para um 500 genérico, registre detalhes no lado do servidor.
- Inferência concorrente ilimitada - thrash de VRAM. Correção: semáforo limitando forwards paralelos.
Alternativas
| Alternativa | Usar Quando | Não Usar Quando |
|---|---|---|
| gRPC + tonic | Malha interna de alto QPS | Clientes de navegador precisam de JSON |
| BentoML / Ray Serve | Propriedade do ecossistema Python | Mandatos de operação apenas em Rust |
| Serverless GPU | Tráfego esporádico | Cold start inaceitável |
| Worker de fila em lote | Throughput acima de latência | SLAs de API interativa |
FAQs
Padrão de estado do Axum 0.8?
Router::with_state + extrator State<T> - clone apenas campos Arc internos baratos.
Como carregar o modelo na inicialização?
#[tokio::main] fn async: carregue em main, passe AppState para o router antes de serve.
Streaming de tokens LLM?
Use handler SSE - veja Inferência e Servindo de LLM.
Agrupamento de requisições?
Fila em um worker de canal que agrupa requisições dentro de uma janela de N ms - padrão avançado para utilização de GPU.
Readiness vs liveness?
Liveness: processo ativo. Readiness: modelo carregado e inferência de teste abaixo do limite de latência.
Validação serde?
Use o crate validator ou verificações manuais no comprimento do vetor e nos intervalos de valores antes da alocação de tensor.
Como implantar?
Contêiner mínimo com montagem de volume de modelo, limites de recursos de CPU/GPU e HPA na profundidade da fila ou latência.
Observabilidade?
Traces OpenTelemetry abrangendo HTTP + seção spawn_blocking com atributo de versão do modelo.
Múltiplos modelos?
Rotas separadas ou roteamento de campo model_id para HashMap<String, Arc<Session>>.
Conexão ONNX?
Substitua o stub infer por uma chamada de sessão ort dentro da tarefa de bloqueio.
Relacionado
- ONNX Runtime (ort) - runtime de modelo
- Inferência e Servindo de LLM - streaming
- Melhores Práticas de ML em Rust - checklist de ops
- GPU e Aceleração - seleção de dispositivo
Versões da Stack: Esta página foi escrita para Rust 1.97.0 (edição 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, e Polars 0.46+.