Dwie zadania z użyciem LLM na jednej karcie GPU: vLLM LoRA, agenci oraz dopasowywanie w Azure ML
Umożliwia obsługę ruchu agentów interaktywnych i zbiorczych z jednej karty, dzięki czemu możliwe jest śledzenie wydajności za pomocą LoRA, analiz w ClickHouse, plików typu Blob oraz procesu szkolenia Unsloth w MLflow.
Główne wyzwanie: dwa obciążenia, jeden model, jeden budżet
Wiele zespołów potrzebuje zarówno interaktywnych rozmów z agentami, jak i analiz zbiorczych na bazie tej samej rodziny modeli dostosowanych pod konkretne potrzeby — ale budżet na karty GPU pozwala tylko na jedną kartę. Ta architektura umożliwia obsługę dwóch zadań związanych z LLM przy użyciu pojedynczej karty GPU w Azure: ścieżkę inferencji o niskiej opóźnieniu z adapterami LoRA ładowanymi według zapotrzebowania za pośrednictwem vLLM, oraz ścieżkę analityczną opartą na agentach, która zapisuje strukturyzowane dane do ClickHouse, przy czym szkolenie i dostosowywanie modeli odbywa się w Azure ML przy użyciu Unsloth i MLflow.
vLLM hostuje model bazowy. Adapty LoRA są ładowane według potrzeb, dzięki czemu specjalistyczne zachowania (ton komunikacji, wydobywanie informacji analitycznych, różne warianty wywoływania narzędzi) nie wymagają osobnych pełnych kopii modelu. Należy utrzymywać małe rozmiary adapterów; zbyt wiele rotujących adapterów powoduje gwałtowne wzrosty opóźnień.
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
Szczegóły warstwy agentów: trzy specjalistyczne agenty, jeden orkiestrator
Rozdzielenie obowiązków:
- Router / orkiestrator — klasyfikuje intencję i wybiera narzędzia
- Specjalista od pobierania danych — ściąga kontekst domeny
- Specjalista od analizy — generuje ustrukturyzowane dane do przechowywania
Dzielenie się zasobami GPU do przetwarzania oznacza konieczność ścisłego kontrolowania równoczesności i kolejek, aby interaktywna rozmowa nie była utrudniona przez prace agentów w trybie partii.
Tło analityczne: ClickHouse
Dane generowane przez agenta, które są istotne dla analiz produktu, trafiają do ClickHouse: opóźnienia, wybory narzędzi, identyfikatory pobranych dokumentów, wyniki działania użytkowników. Przechowywanie kolumnowe lepiej pasuje do strumieni zdarzeń o dużej liczbie wartości niż przymusowe przetwarzanie wszystkiego przez bazę danych OLTP.
Azure Blob Storage: warstwa łącząca elementy MLOps
Punkty kontrolne, zestawy danych, artefakty adapterów oraz raporty oceny znajdują się w Blob Storage. Zadania szkoleniowe czytają i zapisują dane tutaj; procesy inferencji pobierają zatwierdzone adaptery według wersji. Traktuj ścieżki plików w Blob Storage jako część umowy API pomiędzy modułem szkolenia a serwisowaniem.
Proces szkoleniowy: Unsloth w Azure ML
Dostosuj proces szkolenia za pomocą Unsloth w celu uzyskania lepszej wydajności i oszczędności pamięci w środowisku obliczeniowym Azure ML. Śledź przebieg zadań w MLflow: hiperparametry, metryki oceny, adresy URI artefaktów. Promuj tylko te adaptery, które przewyższają wartości bazowe na zamrożonym zbiorze do oceny.
Zwaliduj schemat, usuń dane osobowe, sprawdź równowagę klas oraz uniknij wycieku informacji pomiędzy fazą treningu a oceny, zanim skończą się minuty dostępne na GPU. W przypadku błędów walidacji proces zostanie natychmiast zatrzymany.
Doprecyzowanie modelu za pomocą Unsloth, monitorowane w MLflow
Zapisuj wagi adapterów do usługi Blob wraz z niezmienialnymi identyfikatorami wersji. Konfiguracja inferyencji wykorzystuje te identyfikatory wprost – w środowisku produkcyjnym nie ma mowy o użyciu „najnowszej” wersji bez określenia konkretnego identyfikatora.
Bezpieczne obsługiwanie obu typów zadań
- Odrębne kolejki dla zadań interaktywnych i zbiorczych
- Limity jednoczesnego używania poszczególnych adapterów
- Rozgrzewanie domyślnego adaptera; ładowanie specjalistycznych adapterów dopiero w razie potrzeby
- Przekazywanie danych o wykorzystaniu GPU i głębokości kolejki do tego samego ClickHouse (lub innego systemu pomiarowego)
- Wracanie do poprzedniej wersji adaptera poprzez zmianę wskaźnika konfiguracji, a nie poprzez ponowne rozruchanie całej maszyny wirtualnej
Główne wnioski
Jedna karta GPU może obsługiwać dwa interfejsy produktowe, jeśli zaawansowane rozwiązania takie jak dynamiczne ładowanie LoRA, izolacja kolejek oraz ścisła dyscyplina w zarządzaniu artefaktami MLOps są priorytetem. Kompleks vLLM + Unsloth + Azure ML + Blob + ClickHouse stanowi praktyczne rozwiązanie MLOps dla zespołów, które nie mogą sobie pozwolić na posiadanie kilku zespołów obliczeniowych, ale nadal potrzebują specjalistycznych funkcji i analiz.
Uwaga dotycząca planowania przepustowości: mierz liczbę tokenów na sekundę przy użyciu jednego adaptera w porównaniu z N adapterami pod obciążeniem interaktywnym. Dynamiczne ładowanie LoRA nie jest bezkosztowe – koszty ładowania oraz fragmentacja pamięci mogą zniwelować oszczędności wynikające z użycia „jednej karty GPU”, jeśli ruch produktowy ignoruje różne klasy kolejek. Ustal sztywny limit liczby jednoczesnych adapterów i najpierw eliminuj prace zbiorcze, gdy interaktywne wskaźniki jakości spadną.
Uwaga dotycząca planowania przepustowości: mierz liczbę tokenów na sekundę przy użyciu jednego adaptera w porównaniu z N adapterami pod obciążeniem interaktywnym jednoczesnym. Demand LoRA nie jest bezpłatny – koszty ładowania oraz fragmentacja pamięci mogą zniwelować oszczędności wynikające z użycia „jednej karty graficznej”, jeśli ruch produktowy ignoruje klasy kolejek. Ustal sztywny limit liczby jednoczesnych adapterów i najpierw odrzucaj zadania zbiorcze, gdy SLO interaktywne nie są spełniane.
Uwaga dotycząca planowania przepustowości: mierz liczbę tokenów na sekundę przy użyciu jednego adaptera w porównaniu z N adapterami pod obciążeniem interaktywnym jednoczesnym. Demand LoRA nie jest bezpłatny – koszty ładowania oraz fragmentacja pamięci mogą zniwelować oszczędności wynikające z użycia „jednej karty graficznej”, jeśli ruch produktowy ignoruje klasy kolejek. Ustal sztywny limit liczby jednoczesnych adapterów i najpierw odrzucaj zadania zbiorcze, gdy SLO interaktywne nie są spełniane.
Uwaga dotycząca planowania przepustowości: mierz liczbę tokenów na sekundę przy użyciu jednego adaptera w porównaniu z N adapterami pod obciążeniem interaktywnym jednoczesnym. Demand LoRA nie jest bezpłatny – koszty ładowania oraz fragmentacja pamięci mogą zniwelować oszczędności wynikające z użycia „jednej karty graficznej”, jeśli ruch produktowy ignoruje klasy kolejek. Ustal sztywny limit liczby jednoczesnych adapterów i najpierw odrzucaj zadania zbiorcze, gdy SLO interaktywne nie są spełniane.
Uwaga dotycząca planowania przepustowości: mierz liczbę tokenów na sekundę przy użyciu jednego adaptera w porównaniu z N adapterami pod obciążeniem interaktywnym jednoczesnym. Demand LoRA nie jest bezpłatny – koszty ładowania oraz fragmentacja pamięci mogą zniwelować oszczędności wynikające z użycia „jednej karty graficznej”, jeśli ruch produktowy ignoruje klasy kolejek. Ustal sztywny limit liczby jednoczesnych adapterów i najpierw odrzucaj zadania zbiorcze, gdy SLO interaktywne nie są spełniane.
Uwaga dotycząca planowania przepustowości: mierz liczbę tokenów na sekundę przy użyciu jednego adaptera w porównaniu z N adapterami pod obciążeniem interaktywnym jednoczesnym. Demand LoRA nie jest bezpłatny – koszty ładowania oraz fragmentacja pamięci mogą zniwelować oszczędności wynikające z użycia „jednej karty graficznej”, jeśli ruch produktu ignoruje klasy kolejek. Ustal sztywny limit liczby jednoczesnych adapterów i najpierw odrzucaj zadania zbiorcze, gdy SLO interaktywne nie są spełniane.
Uwaga dotycząca planowania przepustowości: mierz liczbę tokenów na sekundę przy użyciu jednego adaptera w porównaniu z N adapterami pod obciążeniem interaktywnym jednoczesnym. Demand LoRA nie jest bezpłatny – koszty ładowania oraz fragmentacja pamięci mogą zniwelować oszczędności wynikające z użycia „jednej karty graficznej”, jeśli ruch produktu ignoruje klasy kolejek. Ustal sztywny limit liczby jednoczesnych adapterów i najpierw odrzucaj zadania zbiorcze, gdy SLO interaktywne nie są spełniane.
Uwaga dotycząca planowania przepustowości: mierz liczbę tokenów na sekundę przy użyciu jednego adaptera w porównaniu z N adapterami pod obciążeniem interaktywnym jednoczesnym. Demand LoRA nie jest bezpłatny – koszty ładowania oraz fragmentacja pamięci mogą zniwelować oszczędności wynikające z użycia „jednej karty graficznej”, jeśli ruch produktu ignoruje klasy kolejek. Ustal sztywny limit liczby jednoczesnych adapterów i najpierw odrzucaj zadania zbiorcze, gdy SLO interaktywne nie są spełniane.
Uwaga dotycząca planowania przepustowości: mierz liczbę tokenów na sekundę przy użyciu jednego adaptera w porównaniu z N adapterami pod obciążeniem interaktywnym jednoczesnym. Demand LoRA nie jest bezpłatny – koszty ładowania oraz fragmentacja pamięci mogą zniwelować oszczędności wynikające z użycia „jednej karty graficznej”, jeśli ruch produktu ignoruje klasy kolejek. Ustal sztywny limit liczby jednoczesnych adapterów i najpierw odrzucaj zadania zbiorcze, gdy SLO interaktywne nie są spełniane.
Uwaga dotycząca planowania przepustowości: mierz liczbę tokenów na sekundę przy użyciu jednego adaptera w porównaniu z N adapterami pod obciążeniem interaktywnym jednoczesnym. Demand LoRA nie jest bezpłatny – koszty ładowania oraz fragmentacja pamięci mogą zniwelować oszczędności wynikające z użycia „jednej karty graficznej”, jeśli ruch produktu ignoruje klasy kolejek. Ustal sztywny limit liczby jednoczesnych adapterów i najpierw odrzucaj zadania zbiorcze, gdy SLO interaktywne nie są spełniane.
Uwaga dotycząca planowania przepustowości: mierz liczbę tokenów na sekundę przy użyciu jednego adaptera w porównaniu z N adapterami pod obciążeniem interaktywnym jednoczesnym. Demand LoRA nie jest bezpłatny – koszty ładowania oraz fragmentacja pamięci mogą zniwelować oszczędności wynikające z użycia „jednej karty graficznej”, jeśli ruch produktu ignoruje klasy kolejek. Ustal sztywny limit liczby jednoczesnych adapterów i najpierw odrzucaj zadania zbiorcze, gdy SLO interaktywne nie są spełniane.
Uwaga dotycząca planowania przepustowości: mierz liczbę tokenów na sekundę przy użyciu jednego adaptera w porównaniu z N adapterami pod obciążeniem interaktywnym jednoczesnym. Demand LoRA nie jest bezpłatny – koszty ładowania oraz fragmentacja pamięci mogą zniwelować oszczędności wynikające z użycia „jednej karty graficznej”, jeśli ruch produktu ignoruje klasy kolejek. Ustal sztywny limit liczby jednoczesnych adapterów i najpierw odrzucaj zadania zbiorcze, gdy SLO interaktywne nie są spełniane.
Uwaga dotycząca planowania przepustowości: mierz liczbę tokenów na sekundę przy użyciu jednego adaptera w porównaniu z N adapterami pod obciążeniem interaktywnym jednoczesnym. Demand LoRA nie jest bezpłatny – koszty ładowania oraz fragmentacja pamięci mogą zniwelować oszczędności wynikające z użycia „jednej karty graficznej”, jeśli ruch produktu ignoruje klasy kolejek. Ustal sztywny limit liczby jednoczesnych adapterów i najpierw odrzucaj zadania zbiorcze, gdy SLO interaktywne nie są spełniane.
Uwaga dotycząca planowania przepustowości: zmierz liczbę tokenów na sekundę przy użyciu jednego adaptera w porównaniu z N adapterami pod obciążeniem interaktywnym. Usługa Demand LoRA nie jest bezpłatna – koszty ładowania oraz fragmentacja pamięci mogą zniwelować oszczędności wynikające z użycia „jednej karty graficznej”, jeśli ruch w produkcie ignoruje klasy kolejek. Ustal sztywny limit jednoczesnie działających adapterów i najpierw usuwaj prace zbiorcze, gdy interaktywne SLO nie są spełniane.