Два завады LLM на аднай GPU: vLLM LoRA, агенты і налагоджэнне Azure ML
Адмініструйце інтэрактыўны та пакетны трафік агентаў з адной карты за дапамогою падтрымкі LoRA, аналітыкі ClickHouse, файлаў у формате Blob, а також трэніравання Unsloth, якое фіксуецца ў MLflow.
Асалінгавыя задачы: два вароткі, адзін модель, адзін бюджет
Багатыя команды патрабуе як інтэрактыўныя чаты з агентамі, так і аналіз дадзэнняў у пакетах, выкарыстоўваючы адну і тую ж семью дапрацоўваных модэляў — але бюджет на 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-ы не дастаюць неабходных рэзультатаў.