Два завады LLM на аднай GPU: vLLM LoRA, агенты і налагоджэнне Azure ML
Адмініструйце інтэрактыўны і пакетны трафік агентаў з адной карты за дапамогою падтрымкі LoRA, аналітыкі ClickHouse, файлаў у фармаце Blob, а таксама трэніравання Unsloth, якое фіксуецца ў MLflow.
Крыніцы: Serving Two LLM Workloads from One GPU: A Practical MLOps Architecture on Azure
Асалінгавыя задачы: два вароткі, адзін мадэль, адзін бюджэт
Багатыя команды патрабуе як інтэрактыўныя чаты з агентамі, так і аналіз дадзэнняў у пакетах, выкарыстоўваючы адну і тую ж семью дапрацоўваных модэляў — але бюджэт на GPU дазволяе толькі адну карцу. Гэтая архітэктура дазволяе обслужваць два вароткі LLM на адной GPU у Azure: шлях інферэнса з нізкім часам адпаведзення за дапамогою адаптараў LoRA, якія запускаюцца за патрабаваннем праз vLLM, і шлях аналізу з вікорыстаннем агентоў, які запісвае структураваныя дадзеныя ў ClickHouse, пры чым навчанне/дапрацоўкі вядуцца на Azure ML за дапамогою Unsloth і MLflow.
Шар інферэнса: vLLM + адаптары LoRA за патрабаваннем
vLLM выконвае ролю базовай модэлі. Адаптары LoRA запускаюцца за патрабаваннем, таму спецыялізаваныя функцыі (тон падтрымкі, выкарыстоўванне аналітыкі, розныя варыянты вызову інструментаў) не патрабуюць окрэмых копій модэля. Размеры адаптараў трэба залічваць малымі; занадта большая колькасць адаптараў можа спрычыніць рост часу адпаведзення.
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
Слоў агентаў: трое спецыялізаваных агентаў, адны оркестрабірач
Раздэленыя абавязакі:
- Рутэр/оркестрабірач — класыфікуе намер і выбірае інструменты
- Спецяліст па запошчанні дадзеных — заглядае ў кантэкст домэна
- Спецяліст па аналітыцы — выдае структураваныя факты для зберагачвання
Адна GPU для выканання расчыткаў значыць, што трэба старанна контролаваць канкурантнасць і арэйкеты, ўбліжча каб інтэрактыўны чат не страдаў ад работы батч-агентаў.
Аналітычны бэкенд: ClickHouse
Рэзультаты дзеяння агента, якія маюць значэнне для аналізу продукту, зберагаюцца ў ClickHouse: час адказу, выбор засобаў, ID запрашаных дакументаў, рэзультаты дзеяння корыстувальніка. Колонны спосаб зберагання лепей падходзіць для потокаў зместу з вялікай кантальнасцю, чым працэўка всіх дадзенняў праз базу дакументаў тыпу OLTP.
Azure Blob Storage: шар з’еднання для MLOps
Чэкпоінты, наборы дадзенняў, артыфакты адаптараў і справаздачы пра ацэнку зберагаюцца ў Blob. Задачы навчання чытаюць/запішуць дадзеныя тут; працэсы інферэнса запоўняюцься адаптарамі па ўзгаданай версіі. Спачатку трэба спрыяць тым, каб шляхі да блобаў былі частынай кантракту API межу навчальным і сервісным компанентамі.
Працэс навчання: Unsloth на Azure ML
Для падвышэння шчыліны і эфекыванасці памяці пад час навчання на вычысловых рэсурсах Azure ML можна вікорыстоўваць Unsloth. Рэзультаты запускаў фіксуюцца ў MLflow: гіперпараметры, паказнікі ацэнкі, URI артыфактаў. Адаптары можна прыняць толькі тады, калі яны парадуюць лепшымі рэзультатамі, чым базовы варыянт на зафіксаваным наборэ дадзенняў для ацэнкі.
Парабяранне дадзенняў прычыну навчання
Прыгніцеяйце схему, адчысціце персональныя даны, падтрымайце баланс класаў і запобегніце вытэкам между фазамі трэнавання і ацэнкі, пакуль не будзе выкарыстоўвацца час GPU. У разе памылак у прыгніцеяванні работа будзе зупінена.
Тонкая наладка за дапамою Unsloth, фіксуемая ў MLflow
Запішыце вагі адаптараў у Blob з незменнымі ідэнтыфікаторамі версій. Канфігурацыя аналіза прыкладоў явным чынам асабліва пазначае гэтыя ідэнтыфікаторы — у працоўным рэжыме не можна вярнуцца да “пасляпэўнай” версіі без адпаведнага піна.
Безбедная обслугаванне двух тыпаў завад
- Адзінольныя чэргі для інтэрактыўных і пакетных заданняў
- Ліміты канкурантнасці для кожнага адаптара
- Падготавіце стандартны адаптар; спецыяльныя адаптары загрузіце пазней
- Адправляйце даны пра выкарыстоўванне GPU і глыбіну чэргі ў той жа ClickHouse (або іншы сістэма збору метрык)
- Верніцеся да пярвоначальных наларадкаў, змініўшы паказчык канфігурацыі, а не перзагружаючы весь віртуальны машыну
Вывары
Адзін GPU можа абсалваваць два продуктовыя інтэрфейсы, калі выкарыстоўваецца метод LoRA для керавання запотрэбаванням, ізоляцыя абаранак і суворая дысцыпліна ў стварэнні артыфактав у рамках MLOps. Комплекс vLLM + Unsloth + Azure ML + Blob + ClickHouse ёсць практычны падход у MLOps для команд, якія ўжо не можуць сабе парадзіць дубліруючыміся флотамі для апрацоўкі дадзеных, але якім все ж неабходны спецыялізаваныя функцыі і аналітыка.
Прыметкі па планаванню працэспрасоў: меркуйце колькісць токенав за секунду з аднім адаптаром протыраць N адаптароў пад адночасным інтэрактывным навантажэнням. Метод LoRA для керавання запотрэбаванням не ёсць безкоштовным — выкарыстоўванне такога методу і фрагментацыя памяці можуць знішчыць заўдзячэння ад вікорыстоўвання “адзінага GPU”, калі трафік продукту ігноруе класы абаранак. Заставьце жорсткі ліміт для адночасна працюючых адаптароў і спачатку скасавайце пакетную апрацоўку дадзеных, калі інтэрактывныя показнікі SLO паслабляюцца.
Запіска параджэнтавання магчымасоў: вымераюце колькісць токенаў за секунду з аднім адаптаром протыраў N адаптараў пад адночасным інтэрактывным навантажэнням. Demand LoRA не ў бесплатнае выкананне — вартасць завантажэння і фрагментацыя памяці можу знішчыць заўдзячання ад „аднага GPU“, калі трафік продукту ігнаруе класы чакальных ляйнаў. Установіце строгі ліміт для адночасна працюючых адаптароў і спачатку паслабіце працу пакетаў, калі інтэрактывныя SLO-ы не дастаюцься падтрымаць.
Запіска параджэнтавання магчымасоў: вымераюце колькісць токенаў за секунду з аднім адаптаром протыраў N адаптараў пад адночасным інтэрактывным навантажэнням. Demand LoRA не ў бесплатнае выкананне — вартасць завантажэння і фрагментацыя памяці можу знішчыць заўдзячання ад „аднаго GPU“, калі трафік продукту ігнаруе класы чакальных ляйнаў. Установіце строгі ліміт для адночасна працюючых адаптароў і спачатку паслабіце працу пакетаў, калі інтэрактывныя SLO-ы не дастаюцься падтрымаць.
Запіска параджэнтавання магчымасоў: вымераюце колькісць токенаў за секунду з аднім адаптаром протыраў N адаптараў пад адночасным інтэрактывным навантажэнням. Demand LoRA не ўжо безкоштовны — вартасць завантажэння і фрагментацыя памяці можу знішчыць заўдзячання ад „аднаго GPU“, калі трафік продукту ігнаруе класы чакальных ляйнаў. Установіце строгі ліміт для адночасна працюючых адаптароў і спачатку паслабіце працу пакетаў, калі інтэрактывныя SLO-ы паслабляюцься.
Запіска параджэнтавання магчымасоў: вымераюце колькісць токенаў за секунду з аднім адаптаром протыраў N адаптараў пад адночасным інтэрактывным навантажэнням. Demand LoRA не ўжо безкоштовны — вартасць завантажэння і фрагментацыя памяці можу знішчыць заўдзячання ад „аднаго GPU“, калі трафік продукту ігнаруе класы чакальных ляйнаў. Установіце строгі ліміт для адночасна працюючых адаптароў і спачатку паслабіце працу пакетаў, калі інтэрактывныя SLO-ы паслабляюцься.
Запіска параджэнтавання магчымасоў: вымераюце колькісць токенаў за секунду з аднім адаптаром протыраў N адаптараў пад адночасным інтэрактывным навантажэнням. Demand LoRA не ў безплатнае выкананне — вартасць завантажэння і фрагментацыя памяці можу знішчыць заўдзячання ад „аднага GPU“, калі трафік продукту ігнаруе класы чакальных ляйнаў. Установіце строгі ліміт для адночасна працюючых адаптароў і спачатку паслабіце працу пакетаў, калі інтэрактывныя SLO-ы не дастаюцца адпаведна плану.
Запіска параджэнтавання магчымасоў: вымераюце колькісць токенаў за секунду з аднім адаптаром протыраў N адаптараў пад адночасным інтэрактывным навантажэнням. Demand LoRA не ў безплатнае выкананне — вартасць завантажэння і фрагментацыя памяці можу знішчыць заўдзячання ад „аднаго GPU“, калі трафік продукту ігнаруе класы чакальных ляйнаў. Установіце строгі ліміт для адночасна працюючых адаптароў і спачатку паслабіце працу пакетаў, калі інтэрактывныя SLO-ы не дастаюцца адпаведна плану.
Запіска параджэнтавання магчымасоў: вымераюце колькісць токенаў за секунду з аднім адаптаром протыраў N адаптараў пад адночасным інтэрактывным навантажэнням. Demand LoRA не ўжо безкоштовны — вартасць завантажэння і фрагментацыя памяці можу знішчыць заўдзячання ад „аднага GPU“, калі трафік продукту ігнаруе класы чакальных ляйнаў. Установіце строгі ліміт для адночасна працюючых адаптароў і спачатку паслабіце працу пакетаў, калі інтэрактывныя SLO-ы паслабляюцься.
Запіска параджэнтавання магчымасоў: вымераюце колькісць токенаў за секунду з аднім адаптаром протыраў N адаптараў пад адночасным інтэрактывным навантажэнням. Demand LoRA не ўжо безкоштовны — вартасць завантажэння і фрагментацыя памяці можу знішчыць заўдзячання ад „аднаго GPU“, калі трафік продукту ігнаруе класы чакальных ляйнаў. Установіце строгі ліміт для адночасна працюючых адаптароў і спачатку паслабіце працу пакетаў, калі інтэрактывныя SLO-ы паслабляюцься.
Запіска параджэнтавання магчымасоў: вымераюце колькісць токенаў за секунду з аднім адаптаром протыраў N адаптараў пад адночасным інтэрактывным навантажэнням. Demand LoRA не ў бесплатнае выкананне — вартасць завантажэння і фрагментацыя памяці можу знішчыць заўдзячання ад „аднага GPU“, калі трафік продукту ігнаруе класы чакальных ляйнаў. Установіце строгі ліміт для адночасна працюючых адаптароў і спачатку паслабіце працу пакетаў, калі інтэрактывныя SLO-ы не дастаюцься падтрымаць.
Запіска параджэнтавання магчымасоў: вымераюце колькісць токенаў за секунду з аднім адаптаром протыраў N адаптараў пад адночасным інтэрактывным навантажэнням. Demand LoRA не ў бесплатнае выкананне — вартасць завантажэння і фрагментацыя памяці можу знішчыць заўдзячання ад „аднаго GPU“, калі трафік продукту ігнаруе класы чакальных ляйнаў. Установіце строгі ліміт для адночасна працюючых адаптароў і спачатку паслабіце працу пакетаў, калі інтэрактывныя SLO-ы не дастаюцься падтрымаць.
Запіска параджэнтавання магчымасоў: вымераюце колькісць токенаў за секунду з аднім адаптаром протыраў N адаптараў пад адночасным інтэрактывным навантажэнням. Demand LoRA не ў безплатнае выкананне — вартасць завантажэння і фрагментацыя памяці можу знішчыць заўдзячання ад „аднага GPU“, калі трафік продукту ігнаруе класы чакальных ляйнаў. Установіце строгі ліміт для адночасна працюючых адаптароў і спачатку паслабіце працу пакетаў, калі інтэрактывныя SLO-ы не дастаюцься падтрымаць.
Запіска параджэнтавання магчымасоў: вымераюце колькісць токенаў за секунду з аднім адаптаром протыраў N адаптараў пад адночасным інтэрактывным навантажэнням. Demand LoRA не ў безплатнае выкананне — вартасць завантажэння і фрагментацыя памяці можу знішчыць заўдзячання ад „аднаго GPU“, калі трафік продукту ігнаруе класы чакальных ляйнаў. Установіце строгі ліміт для адночасна працюючых адаптароў і спачатку паслабіце працу пакетаў, калі інтэрактывныя SLO-ы не дастаюцься падтрымаць.
Запіска парадоку пры планаванні ўместнасці: вы магчымае змерыць колькасць токенав за секунду з аднім адаптэрам проты N адаптэраў пад адночасным інтэрактывным навантажэнням. Сервіс Demand LoRA не ёсць безкоштовным — вартасць завантажэння і фрагментацыя памяці можу знішчыць заўдзячэння ад „адной GPU“, калі трафік продукту ігнаруе класы чакальных ляйнаў. Заставьце жорсткі ліміт для адночасных адаптэраў і спачатку паслабіце працу пакетаў, калі інтэрактывныя SLO-ы не дастаюць неабходных рэзультатаў.