Deux charges de travail d’LLM sur une seule GPU : vLLM LoRA, agents, et affinage avec Azure ML
Gérez le trafic des agents interactifs et en lot depuis une seule carte, avec LoRA adaptatif à la demande, des analyses ClickHouse, des artefacts Blob, ainsi que l’entraînement Unsloth suivi via MLflow.
Le défi principal : deux charges de travail, un modèle, un budget
De nombreuses équipes ont besoin à la fois de conversations avec des agents interactifs et d’analyses par lots utilisant la même famille de modèles affinés — mais le budget alloué aux GPU ne permet qu’une seule carte. Cette architecture prend en charge deux types de charges de travail basés sur des LLM à partir d’une seule GPU sur Azure : un chemin d’inférence à faible latence avec des adaptateurs LoRA chargés selon la demande via vLLM, et un chemin d’analyse agente qui enregistre des événements structurés dans ClickHouse, avec un entraînement/affinage sur Azure ML à l’aide d’Unsloth et de MLflow.
La couche d’inférence : vLLM + LoRA sur demande
vLLM héberge le modèle de base. Les adaptateurs LoRA s’chargent selon la demande afin que des comportements spécialisés (ton de support, extraction d’analyses, variantes d’appel d’outils) n’aient pas besoin chacun d’une réplique complète. Gardez la taille des adaptateurs petite ; une surcharge se traduit par des pics de latence lorsque trop d’adaptateurs tournent en même temps.
vllm serve your-org/base-vlm-7b \
--enable-lora \
--max-lora-rank 64 \
--lora-modules domain-adapter=/data/lora-adapters/current/ \
--host 0.0.0.0 \
--port 8000 \
--gpu-memory-utilization 0.90 \
--max-model-len 8192
payload = {
"model": "domain-adapter",
"messages": [
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}"}},
{"type": "text", "text": f"Analyse this for category: {category}, location: {label}"}
]}
],
"max_tokens": 1024
}
response = requests.post(VLLM_API_URL, json=payload, timeout=30,
proxies={"http": None, "https": None})
from agents.extensions.models.litellm_model import LitellmModel
model = LitellmModel(
model="openai/base-vlm-7b",
base_url="http://10.x.x.x:8000/v1",
api_key="not-needed"
)
from agents import Agent, Runner, function_tool as tool
@tool
def get_dashboard_snapshot_tool() -> Dict[str, Any]:
"""Get all KPIs in one call. Call this FIRST for any summary question."""
with SessionLocal() as db:
return jsonable_encoder(analytics_service.get_dashboard_snapshot(db))
@tool
def get_entity_ranking_tool(top: bool = True) -> List[Dict[str, Any]]:
"""Get ranked entity performance list. top=True for best, False for worst."""
with SessionLocal() as db:
return jsonable_encoder(analytics_service.get_performance_ranking(db, top))
overview_agent = Agent(
name="OverviewAgent",
instructions="Handle general dashboard and summary questions. Always call get_dashboard_snapshot_tool first.",
tools=[get_dashboard_snapshot_tool, get_entity_ranking_tool],
model=model,
)
status_agent = Agent(
name="StatusAgent",
instructions="Handle live status and stream/field health questions.",
tools=[get_stream_status_tool, get_field_status_tool],
model=model,
)
quality_agent = Agent(
name="QualityAgent",
instructions="Handle rejection-rate and data-quality questions.",
tools=[get_rejection_stats_tool],
model=model,
)
orchestrator = Agent(
name="AnalyticsOrchestrator",
instructions=(
"Handle all user communication. Route each question to the right specialist tool "
"and synthesize its result into a plain-text answer. Do not hallucinate metrics; "
"only report what a specialist returns."
),
tools=[
overview_agent.as_tool(
tool_name="overview_expert",
tool_description="Answers general dashboard and summary questions.",
),
status_agent.as_tool(
tool_name="status_expert",
tool_description="Answers live status and stream/field health questions.",
),
quality_agent.as_tool(
tool_name="quality_expert",
tool_description="Answers rejection-rate and data-quality questions.",
),
],
)
CREATE TABLE events_queue (
event_id UUID,
entity_id UInt64,
event_type LowCardinality(String),
payload String,
created_at DateTime64(3)
) ENGINE = Kafka
SETTINGS
kafka_broker_list = 'your-namespace.servicebus.windows.net:9093',
kafka_topic_list = 'app-events',
kafka_group_name = 'clickhouse-consumer',
kafka_format = 'JSONEachRow';
CREATE MATERIALIZED VIEW events_mv TO events AS
SELECT * FROM events_queue;
azureblob://
├── ml-artifacts/
│ ├── lora-adapters/{v1, v2, v3}
│ ├── base-models/base-vlm-7b/
│ └── eval-results/v3/
├── training-data/{raw, processed, annotations}
└── inference-inputs/{year}/{month}/{day}/{entity_id}/{request_id}.bin
import great_expectations as gx
context = gx.get_context()
batch = context.sources.pandas_default.read_json("annotations_batch.jsonl")
suite = context.get_expectation_suite("annotation_schema_v1")
results = context.run_validation_operator(
"action_list_operator",
assets_to_validate=[batch],
run_id="training-v4-ingestion"
)
if not results["success"]:
raise ValueError(f"Data validation failed: {results['statistics']}")
import mlflow
from unsloth import FastVisionModel
mlflow.set_experiment("domain-lora-finetuning")
with mlflow.start_run(run_name=f"lora-v{adapter_version}") as run:
model, tokenizer = FastVisionModel.from_pretrained(
"unsloth/base-vlm-7b",
load_in_4bit=True,
)
model = FastVisionModel.get_peft_model(
model,
r=64,
lora_alpha=128,
finetune_vision_layers=True,
)
mlflow.log_params({
"base_model": "base-vlm-7b",
"lora_rank": 64,
"lora_alpha": 128,
"epochs": 3,
"dataset_version": "v4",
})
for epoch in range(epochs):
train_loss = train_one_epoch(model, dataloader)
mlflow.log_metric("train_loss", train_loss, step=epoch)
metrics = evaluate(model, eval_dataloader)
mlflow.log_metrics({"eval_f1": metrics["f1"]})
mlflow.log_artifacts(output_dir, artifact_path="lora-adapter")
mlflow.set_tag("promotion_status", "candidate")
# azure-ml-lora-job.yml
$schema: https://azuremlschemas.azureedge.net/latest/commandJob.schema.json
type: command
code: ./training/
command: >
python finetune_lora_unsloth.py
--dataset ${{inputs.training_data}}
--output-dir ${{outputs.lora_adapter}}
--lora-rank 64 --lora-alpha 128 --epochs 3
inputs:
training_data:
type: uri_folder
path: azureml://datastores/training_blob/paths/domain/v4/
outputs:
lora_adapter:
type: uri_folder
path: azureml://datastores/artifacts_blob/paths/lora-adapters/v4/
environment: azureml:vlm-unsloth-env:1
compute: azureml:gpu-cluster-nc24ads # single A100, sufficient for Unsloth LoRA fine-tuning
experiment_name: domain-lora-finetuning
from azure.ai.ml.entities import Model
model = Model(
name="domain-lora-adapter",
version="4",
path="azureml://datastores/artifacts_blob/paths/lora-adapters/v4/",
tags={
"base_model": "base-vlm-7b",
"lora_rank": "64",
"eval_f1": "0.93", # illustrative
"promotion_status": "candidate",
}
)
ml_client.models.create_or_update(model)
def run_eval(adapter_version: str, eval_dataset_version: str):
candidate = evaluate_adapter(adapter_path=..., eval_data=...)
production = evaluate_adapter(adapter_path=get_production_adapter_path(), eval_data=...)
passed = (
candidate["f1"] >= production["f1"] - 0.01
and candidate["f1"] >= MIN_F1_THRESHOLD
)
return passed, candidate
New annotation batch → Great Expectations validation
→ dataset versioned in Azure ML
→ Azure ML training job (Unsloth, single GPU, spot instance)
→ offline eval vs. production, per-category
→ [pass] tag adapter "production" in registry
→ sync adapter to local disk on the vLLM host
→ rolling restart of vLLM with new --lora-modules path
# .gitlab-ci.yml
stages:
- validate
- train
- eval
- deploy
validate-data:
stage: validate
script:
- python eval/validate_dataset.py --version $DATASET_VERSION
submit-training:
stage: train
needs: [validate-data]
script:
- az ml job create --file azure-ml-lora-job.yml
--set inputs.dataset_version=$DATASET_VERSION
--workspace-name $AML_WORKSPACE --resource-group $RESOURCE_GROUP
run-eval:
stage: eval
needs: [submit-training]
script:
- python eval/run_eval.py --version $DATASET_VERSION
- python scripts/promote_adapter.py --version $DATASET_VERSION
deploy:
stage: deploy
needs: [run-eval]
when: manual
script:
- az containerapp update --name vllm-server --resource-group $RESOURCE_GROUP
--set-env-vars LORA_ADAPTER_VERSION=$DATASET_VERSION
La couche d’agents : trois agents spécialisés, un orchestrateur
Répartition des responsabilités :
- Routeur/orchestrateur — classe l’intention et sélectionne les outils
- Specialiste du récupération — obtient le contexte du domaine
- Specialiste de l’analyse — génère des faits structurés à stocker
L’inference sur GPU partagé exige des limites de concurrence et une gestion rigoureuse des files d’attente afin que la conversation interactive ne soit pas ralentie par les exécutions en lot des agents.
Le backend d’analyse : ClickHouse
Les données générées par les agents et importantes pour l’analyse des produits sont stockées dans ClickHouse : latence, choix d’outils, IDs des documents récupérés, résultats pour les utilisateurs. Le stockage colonne convient mieux aux flux d’événements à haute cardinalité qu’en forçant tout à passer par une base de données OLTP.
Azure Blob Storage : la couche de liaison MLOps
Les points de contrôle, les ensembles de données, les artefacts des adaptateurs et les rapports d’évaluation sont stockés dans Blob. Les tâches d’entraînement lisent/écrivent ici ; l’inference récupère les adaptateurs approuvés selon leur version. Considérez les chemins des blobs comme faisant partie du contrat API entre l’entraînement et le service.
Le pipeline d’entraînement : Unsloth sur Azure ML
Ajustez les paramètres avec Unsloth pour obtenir une meilleure efficacité en termes de vitesse et de mémoire sur les ressources d’Azure ML. Suivez les exécutions via MLflow : hyperparamètres, métriques d’évaluation, URI des artefacts. Ne promouvez que les adaptateurs qui surpassent une valeur de référence sur un ensemble d’évaluation figé.
Vérification des données avant l’entraînement
Vérifiez le schéma, le nettoyage des données sensibles, l’équilibre des classes ainsi que les fuites entre les ensembles d’entraînement et d’évaluation avant qu’il ne soit trop tard. Les erreurs de validation entraînent une fermeture immédiate du processus.
Ajustement fin avec Unsloth, suivi via MLflow
Enregistrez les poids des adaptateurs dans Blob avec des identifiants de version immuables. La configuration d’inférence fait référence explicitement à ces identifiants — il n’y a pas de version « la plus récente » en production sans identification précise.
Fournir des services sécurisés pour les deux types de charges de travail
- Filets d’attente séparés pour les traitements interactifs et en lot
- Limites de concurrence par adaptateur
- Mettre à jour en mémoire un adaptateur par défaut ; charger en arrière-plan les adaptateurs spécialisés
- Envoyer des données sur l’utilisation du GPU et la profondeur du filet d’attente vers le même ClickHouse (ou stack de métriques)
- Réinitialiser les adaptateurs en modifiant simplement un pointeur de configuration, sans avoir à redéployer toute la machine virtuelle
Points clés
Une seule GPU peut gérer deux interfaces de produit si le chargement demandé par LoRA, l’isolation des files d’attente et la discipline liée aux artefacts MLOps sont correctement mis en œuvre. vLLM + Unsloth + Azure ML + Blob + ClickHouse constituent une solution MLOps pratique pour les équipes qui ne peuvent pas se permettre de disposer de flottes d’inférence multiples mais ont néanmoins besoin de fonctionnalités et d’analyses spécialisées.
Remarque sur la planification de la capacité : mesurez le nombre de tokens par seconde avec un adaptateur seul versus N adaptateurs sous une charge interactive simultanée. Le chargement demandé par LoRA n’est pas gratuit — les coûts de chargement et la fragmentation mémoire peuvent annuler les économies liées à l’utilisation d’une seule GPU si le trafic du produit ignore les différentes classes de files d’attente. Imposez une limite stricte au nombre d’adaptateurs simultanés et abandonnez d’abord les tâches en lot lorsque les objectifs de performance interactifs ne sont pas respectés.
Note de planification des capacités : mesurez le nombre de tokens par seconde avec un adaptateur contre N adaptateurs sous une charge interactive concurrentielle. Demand LoRA n’est pas gratuit — les coûts de chargement et la fragmentation mémoire peuvent annuler les économies liées à l’utilisation d’une seule GPU si le trafic du produit ignore les classes de files d’attente. Imposez une limite stricte au nombre d’adaptateurs simultanés et abandonnez en premier les tâches par lots lorsque les objectifs de performance interactifs ne sont pas respectés.
Note de planification des capacités : mesurez le nombre de tokens par seconde avec un adaptateur contre N adaptateurs sous une charge interactive concurrentielle. Demand LoRA n’est pas gratuit — les coûts de chargement et la fragmentation mémoire peuvent annuler les économies liées à l’utilisation d’une seule GPU si le trafic du produit ignore les classes de files d’attente. Imposez une limite stricte au nombre d’adaptateurs simultanés et abandonnez en premier les tâches par lots lorsque les objectifs de performance interactifs ne sont pas respectés.
Note de planification des capacités : mesurez le nombre de tokens par seconde avec un adaptateur contre N adaptateurs sous une charge interactive concurrentielle. Demand LoRA n’est pas gratuit — les coûts de chargement et la fragmentation mémoire peuvent annuler les économies liées à l’utilisation d’une seule GPU si le trafic du produit ignore les classes de files d’attente. Imposez une limite stricte au nombre d’adaptateurs utilisés simultanément et abandonnez en premier les tâches par lots lorsque les objectifs de performance interactifs ne sont pas respectés.
Note de planification des capacités : mesurez le nombre de tokens par seconde avec un adaptateur contre N adaptateurs sous une charge interactive concurrentielle. Demand LoRA n’est pas gratuit — les coûts de chargement et la fragmentation mémoire peuvent annuler les économies liées à l’utilisation d’une seule GPU si le trafic du produit ignore les classes de files d’attente. Imposez une limite stricte au nombre d’adaptateurs utilisés simultanément et abandonnez en premier les tâches par lots lorsque les objectifs de performance interactifs ne sont pas respectés.
Note de planification des capacités : mesurez le nombre de tokens par seconde avec un adaptateur contre N adaptateurs sous une charge interactive concurrentielle. Demand LoRA n’est pas gratuit — les coûts de chargement et la fragmentation mémoire peuvent annuler les économies liées à l’utilisation d’une seule GPU si le trafic du produit ignore les classes de files d’attente. Imposez une limite stricte au nombre d’adaptateurs utilisés simultanément et abandonnez en premier les tâches par lots lorsque les objectifs de performance interactifs ne sont pas respectés.
Note de planification des capacités : mesurez le nombre de tokens par seconde avec un adaptateur contre N adaptateurs sous une charge interactive concurrentielle. Demand LoRA n’est pas gratuit — les coûts de chargement et la fragmentation mémoire peuvent annuler les économies liées à l’utilisation d’une seule GPU si le trafic du produit ignore les classes de files d’attente. Imposez une limite stricte au nombre d’adaptateurs utilisés simultanément et abandonnez en premier les tâches par lots lorsque les objectifs de performance interactifs ne sont pas respectés.
Note de planification des capacités : mesurez le nombre de tokens par seconde avec un adaptateur contre N adaptateurs sous une charge interactive concurrentielle. Demand LoRA n’est pas gratuit — les coûts de chargement et la fragmentation mémoire peuvent annuler les économies liées à l’utilisation d’une seule GPU si le trafic du produit ignore les classes de files d’attente. Imposez une limite stricte au nombre d’adaptateurs utilisés simultanément et abandonnez en premier les tâches par lots lorsque les objectifs de performance interactifs ne sont pas respectés.
Note de planification des capacités : mesurez le nombre de tokens par seconde avec un adaptateur contre N adaptateurs sous une charge interactive concurrentielle. Demand LoRA n’est pas gratuit — les coûts de chargement et la fragmentation mémoire peuvent annuler les économies liées à l’utilisation d’une seule GPU si le trafic du produit ignore les classes de files d’attente. Imposez une limite stricte au nombre d’adaptateurs utilisés simultanément et abandonnez en premier les tâches par lots lorsque les objectifs de performance interactifs ne sont pas respectés.
Note de planification des capacités : mesurez le nombre de tokens par seconde avec un adaptateur contre N adaptateurs sous une charge interactive concurrentielle. Demand LoRA n’est pas gratuit — les coûts de chargement et la fragmentation mémoire peuvent annuler les économies liées à l’utilisation d’une seule GPU si le trafic du produit ignore les classes de files d’attente. Imposez une limite stricte au nombre d’adaptateurs utilisés simultanément et abandonnez en premier les tâches par lots lorsque les objectifs de performance interactifs ne sont pas respectés.
Note de planification des capacités : mesurez le nombre de tokens par seconde avec un adaptateur contre N adaptateurs sous une charge interactive concurrentielle. Demand LoRA n’est pas gratuit — les coûts de chargement et la fragmentation mémoire peuvent annuler les économies liées à l’utilisation d’une seule GPU si le trafic du produit ignore les classes de files d’attente. Imposez une limite stricte au nombre d’adaptateurs utilisés simultanément et abandonnez en premier les tâches par lots lorsque les objectifs de performance interactifs ne sont pas respectés.
Note de planification des capacités : mesurez le nombre de tokens par seconde avec un adaptateur contre N adaptateurs sous une charge interactive concurrentielle. Demand LoRA n’est pas gratuit — les coûts de chargement et la fragmentation mémoire peuvent annuler les économies liées à l’utilisation d’une seule GPU si le trafic du produit ignore les classes de files d’attente. Imposez une limite stricte au nombre d’adaptateurs simultanés et abandonnez en premier les tâches par lots lorsque les objectifs de performance interactifs ne sont pas respectés.
Note de planification des capacités : mesurez le nombre de tokens par seconde avec un adaptateur contre N adaptateurs sous une charge interactive concurrentielle. Demand LoRA n’est pas gratuit — les coûts de chargement et la fragmentation mémoire peuvent annuler les économies liées à l’utilisation d’une seule GPU si le trafic du produit ignore les classes de files d’attente. Imposez une limite stricte au nombre d’adaptateurs simultanés et abandonnez en premier les tâches par lots lorsque les objectifs de performance interactifs ne sont pas respectés.
Note de planification des capacités : mesurez le nombre de tokens par seconde avec un adaptateur contre N adaptateurs sous une charge interactive simultanée. Demand LoRA n’est pas gratuit — les coûts de chargement et la fragmentation mémoire peuvent annuler les économies liées à l’utilisation d’une seule GPU si le trafic du produit ignore les classes de files d’attente. Imposez une limite stricte au nombre d’adaptateurs simultanés et abandonnez d’abord les tâches en lot lorsque les objectifs de performance interactifs ne sont pas respectés.