Головна / Статті / Дві робочі навантаження LLM на одному GPU: vLLM LoRA, агенти та налаштування Azure ML

Дві робочі навантаження LLM на одному GPU: vLLM LoRA, агенти та налаштування Azure ML

Обробляйте інтерактивний та пакетний трафік агентів з однієї картки за допомогою LoRA для задоволення попиту, аналітики ClickHouse, об’єктів типу Blob та процесу навчання Unsloth, який відстежується у MLflow.

1917 слів

Основна проблема: два обсяги роботи, одна модель, один бюджет

Багато команд потребують як інтерактивного чату з агентами, так і пакетної аналітики на основі однієї родини деталізовано налаштованих моделей — але бюджет на GPU дозволяє лише одну карту. Ця архітектура обслуговує два обсяги роботи з ШІ на одному 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

Шар агентів: три спеціалізовані агента та один оркестратор

Розділення обов’язків:

  1. Маршрутизатор/оркестратор — класифікує наміри та обирає інструменти
  2. Спеціаліст з отриманням даних — завантажує контекст домену
  3. Спеціаліст з аналітикою — генерує структуровані дані для зберігання

Спільне використання GPU для обробки даних вимагає ретельного контролю за паралелізмом та чергуванням, щоб інтерактивний чат не страждав від багатоетапної роботи агентів.

Бекенд для аналітики: ClickHouse

Дані, які мають значення для аналітики продукту, зберігаються в ClickHouse: час відгуку, вибір інструментів, ID отриманих документів, результати роботи користувачів. Колонкове зберігання краще підходить для потоків подій із високою кардинальністю, ніж примусове оброблення всього через базу даних OLTP.

Azure Blob Storage: шар з’єднання MLOps

Чекпоїнти, набори даних, артефакти адаптерів та звіти про оцінку зберігаються у Blob. Завдання на навчання читають/записують дані тут; процеси інференсу завантажують схвалені адаптери за версією. Вважайте шляхи до блобів частиною API-контракту між модулями навчання та обслуговування.

Пайплайн навчання: Unsloth у Azure ML

Використовуйте Unsloth для дооптимізації швидкості та ефективності використання пам’яті під час навчання на сервісі Azure ML. Відстежуйте процеси в MLflow: гіперпараметри, показники оцінки, URI артефактів. Просувайте в ужиток лише ті адаптери, які показують кращі результати, ніж базовий варіант, на замороженому наборі даних для оцінки.

Перевірка даних перед навчанням

Перевіряйте схему, очищення персональних даних, баланс класів та відсутність витоку інформації між фазами навчання та оцінки ще до того, як закінчаться хвилини роботи GPU. У разі помилок під час перевірки процес припиняється.

Тонке налаштування за допомогою Unsloth з відстеженням у MLflow

Записуйте ваги адаптерів у Blob із незмінними ідентифікаторами версій. Конфігурація інтерпретації явно посилається на ці ідентифікатори — у продакшені не може використовуватися „найновіша“ версія без вказаного ідентифікатора.

Безпечна обробка обох типів завдань

  • Окремі черги для інтерактивної та пакетної обробки
  • Ліміти одночасної роботи для кожного адаптера
  • Попереднє завантаження стандартного адаптера; додаткове завантаження спеціалізованих
  • Відстеження рівня використання GPU та глибини черг у тому ж ClickHouse (або системі метрик)
  • Відкат адаптерів шляхом зміни вказівника конфігурації, а не шляхом повторного розгортання всієї віртуальної машини

Висновки

Одна GPU може обробляти дві продуктові інтерфейси, якщо ефективне завантаження моделей за допомогою LoRA, ізоляція черг та суворе керування артефактами у MLOps є пріоритетними. Комбінація vLLM + Unsloth + Azure ML + Blob + ClickHouse є практичним рішенням для MLOps для команд, які ще не можуть дозволити собі кілька систем обробки запитів, але все одно потребують спеціалізованих функцій та аналітики.

Примітка щодо планування потужності: вимірюйте кількість токенів на секунду з одним адаптером порівняно з N адаптерами під одночасним інтерактивним навантаженням. Використання LoRA за запитом не є безкоштовним — витрати на завантаження та фрагментація пам’яті можуть знищити переваги використання однієї GPU, якщо трафік продукту ігнорує класи черг. Встановіть жорсткий ліміт на одночасну кількість адаптерів та спочатку припиняйте обробку пакетних завдань, коли порушуються інтерактивні показники якості.

Примітка щодо планування пропускної здатності: вимірюйте кількість токенів на секунду з одним адаптером порівняно з N адаптерами за умов конкурентного інтерактивного навантаження. Demand LoRA не є безкоштовним — витрати на завантаження та фрагментація пам’яті можуть скасувати економію від використання „однієї GPU“, якщо трафік продукту ігнорує класи черг. Встановіть жорсткий ліміт на одночасну кількість адаптерів та спочатку відмовляйтесь від роботи пакетами, коли інтерактивні SLOs не дотримуються встановлених показників.

Примітка щодо планування пропускної здатності: вимірюйте кількість токенів на секунду з одним адаптером порівняно з N адаптерами за умов конкурентного інтерактивного навантаження. Demand LoRA не є безкоштовним — витрати на завантаження та фрагментація пам’яті можуть скасувати економію від використання „однієї GPU“, якщо трафік продукту ігнорує класи черг. Встановіть жорсткий ліміт на одночасну кількість адаптерів та спочатку відмовляйтесь від роботи пакетами, коли інтерактивні SLOs не дотримуються встановлених показників.

Примітка щодо планування пропускної здатності: вимірюйте кількість токенів на секунду з одним адаптером порівняно з N адаптерами за умов конкурентного інтерактивного навантаження. Demand LoRA не є безкоштовним — витрати на завантаження та фрагментація пам’яті можуть скасувати економію від використання „однієї GPU“, якщо трафік продукту ігнорує класи черг. Встановіть жорсткий ліміт на одночасну кількість адаптерів та спочатку відмовляйтесь від роботи пакетами, коли інтерактивні SLOs не дотримуються встановлених показників.

Примітка щодо планування пропускної здатності: вимірюйте кількість токенів на секунду з одним адаптером порівняно з N адаптерами за умов конкурентного інтерактивного навантаження. Demand LoRA не є безкоштовним — витрати на завантаження та фрагментація пам’яті можуть скасувати економію від використання „однієї GPU“, якщо трафік продукту ігнорує класи черг. Встановіть жорсткий ліміт на одночасну кількість адаптерів та спочатку відмовляйтесь від роботи пакетами, коли інтерактивні SLOs не дотримуються встановлених показників.

Примітка щодо планування пропускної здатності: вимірюйте кількість токенів на секунду з одним адаптером порівняно з N адаптерами за умов конкурентного інтерактивного навантаження. Demand LoRA не є безкоштовним — витрати на завантаження та фрагментація пам’яті можуть скасувати економію від використання „однієї GPU“, якщо трафік продукту ігнорує класи черг. Встановіть жорсткий ліміт на одночасну кількість адаптерів та спочатку відмовляйтесь від роботи пакетами, коли інтерактивні SLOs не дотримуються встановлених показників.

Примітка щодо планування пропускної здатності: вимірюйте кількість токенів на секунду з одним адаптером порівняно з N адаптерами за умов конкурентного інтерактивного навантаження. Demand LoRA не є безкоштовним — витрати на завантаження та фрагментація пам’яті можуть скасувати економію від використання „однієї GPU“, якщо трафік продукту ігнорує класи черг. Встановіть жорсткий ліміт на одночасну кількість адаптерів та спочатку відмовляйтесь від роботи пакетами, коли інтерактивні SLOs не дотримуються встановлених показників.

Примітка щодо планування пропускної здатності: вимірюйте кількість токенів на секунду з одним адаптером порівняно з N адаптерами за умов конкурентного інтерактивного навантаження. Demand LoRA не є безкоштовним — витрати на завантаження та фрагментація пам’яті можуть скасувати економію від використання „однієї GPU“, якщо трафік продукту ігнорує класи черг. Встановіть жорсткий ліміт на одночасну кількість адаптерів та спочатку відмовляйтесь від роботи пакетами, коли інтерактивні SLOs не дотримуються встановлених показників.

Примітка щодо планування пропускної здатності: вимірюйте кількість токенів на секунду з одним адаптером порівняно з N адаптерами за умов конкурентного інтерактивного навантаження. Demand LoRA не є безкоштовним — витрати на завантаження та фрагментація пам’яті можуть скасувати економію від використання „однієї GPU“, якщо трафік продукту ігнорує класи черг. Встановіть жорсткий ліміт на одночасну кількість адаптерів та спочатку відмовляйтесь від роботи пакетами, коли інтерактивні SLOs не дотримуються встановлених показників.

Примітка щодо планування пропускної здатності: вимірюйте кількість токенів на секунду з одним адаптером порівняно з N адаптерами за умов конкурентного інтерактивного навантаження. Demand LoRA не є безкоштовним — витрати на завантаження та фрагментація пам’яті можуть скасувати економію від використання „однієї GPU“, якщо трафік продукту ігнорує класи черг. Встановіть жорсткий ліміт на одночасну кількість адаптерів та спочатку відмовляйтесь від роботи пакетами, коли інтерактивні SLOs не дотримуються встановлених показників.

Примітка щодо планування пропускної здатності: вимірюйте кількість токенів на секунду з одним адаптером порівняно з N адаптерами за умов конкурентного інтерактивного навантаження. Demand LoRA не є безкоштовним — витрати на завантаження та фрагментація пам’яті можуть скасувати економію від використання „однієї GPU“, якщо трафік продукту ігнорує класи черг. Встановіть жорсткий ліміт на одночасну кількість адаптерів та спочатку відмовляйтесь від роботи пакетами, коли інтерактивні SLOs не дотримуються встановлених показників.

Примітка щодо планування пропускної здатності: вимірюйте кількість токенів на секунду з одним адаптером порівняно з N адаптерами за умов конкурентного інтерактивного навантаження. Demand LoRA не є безкоштовним — витрати на завантаження та фрагментація пам’яті можуть скасувати економію від використання „однієї GPU“, якщо трафік продукту ігнорує класи черг. Встановіть жорсткий ліміт на одночасну кількість адаптерів та спочатку відмовляйтесь від роботи пакетами, коли інтерактивні SLOs не дотримуються встановлених показників.

Примітка щодо планування пропускної здатності: вимірюйте кількість токенів на секунду з одним адаптером порівняно з N адаптерами за умов конкурентного інтерактивного навантаження. Demand LoRA не є безкоштовним — витрати на завантаження та фрагментація пам’яті можуть скасувати економію від використання „однієї GPU“, якщо трафік продукту ігнорує класи черг. Встановіть жорсткий ліміт на одночасну кількість адаптерів та спочатку відмовляйтесь від роботи пакетами, коли інтерактивні SLOs не дотримуються встановлених показників.

Примітка щодо планування пропускної здатності: вимірюйте кількість токенів на секунду за допомогою одного адаптера та N адаптерів при одночасному інтерактивному навантаженні. Сервіс Demand LoRA не є безкоштовним — витрати на завантаження та фрагментація пам’яті можуть знищити економію від використання „однієї GPU“, якщо трафік продукту ігнорує класи черг. Встановіть суворий ліміт на кількість одночасних адаптерів та спочатку припиняйте обробку пакетних завдань, коли порушуються інтерактивні показники якості.