Modern QA2026Нагрузочное тестирование LLM-эндпоинтов
Join

Course05 Performance & Chaos Engineering

Cutting-edge · Chapter 05

Нагрузочное тестирование LLM-эндпоинтов

Updated Jul 2026

Почему нагрузочное тестирование LLM требует особого внимания

Традиционное нагрузочное тестирование предполагает предсказуемое время ответа и линейное масштабирование. Эндпоинты LLM нарушают оба этих предположения:

  • Переменное время ответа. Простой запрос может завершиться за 1 секунду; сложная задача на рассуждение может занять 30 секунд.
  • Лимиты на основе токенов. Вы можете исчерпать лимит запросов несколькими большими запросами или множеством маленьких.
  • Холодные старты. Serverless-деплои LLM могут добавить 5-30 секунд к первому запросу после простоя.
  • Очередь на стороне провайдера. Когда провайдер под нагрузкой, запросы ставятся в очередь на серверной стороне, что делает клиентскую конкурентность нерелевантной.
  • Нелинейная стоимость. В отличие от традиционных API, каждый запрос к LLM стоит денег пропорционально количеству токенов. Нагрузочный тест, отправляющий 10 000 запросов к GPT-4, может стоить сотни долларов.

Скрипт k6 для нагрузочного тестирования LLM-эндпоинтов

// k6-llm-load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend, Counter, Rate } from 'k6/metrics';

// Custom LLM-specific metrics
const ttft = new Trend('time_to_first_token', true);
const totalGenTime = new Trend('total_generation_time', true);
const tokensPerSecond = new Trend('tokens_per_second');
const rateLimitHits = new Counter('rate_limit_hits');
const timeoutRate = new Rate('timeout_rate');

export const options = {
  scenarios: {
    // Simulate gradual ramp to find the breaking point
    ramp_to_limit: {
      executor: 'ramping-arrival-rate',
      startRate: 1,
      timeUnit: '1s',
      preAllocatedVUs: 50,
      maxVUs: 200,
      stages: [
        { duration: '2m', target: 5 },    // 5 req/s
        { duration: '3m', target: 10 },   // 10 req/s
        { duration: '3m', target: 20 },   // 20 req/s -- likely hits rate limits
        { duration: '2m', target: 5 },    // cool down
      ],
    },
  },
  thresholds: {
    time_to_first_token: ['p(95)<3000'],     // TTFT under 3s for 95th pctile
    total_generation_time: ['p(95)<15000'],   // Total gen under 15s
    tokens_per_second: ['avg>30'],            // At least 30 tok/s average
    timeout_rate: ['rate<0.05'],              // Under 5% timeouts
  },
};

// Varied prompts to simulate realistic usage
const prompts = [
  "Summarize the key differences between REST and GraphQL in 3 sentences.",
  "Write a Python function that validates an email address using regex.",
  "Explain the CAP theorem to a junior developer.",
  "Generate a SQL query to find the top 10 customers by revenue last quarter.",
  "What are the SOLID principles? Give a one-line explanation of each.",
];

export default function () {
  const prompt = prompts[Math.floor(Math.random() * prompts.length)];

  const payload = JSON.stringify({
    model: "gpt-4o",
    messages: [{ role: "user", content: prompt }],
    max_tokens: 256,
    stream: false,
  });

  const startTime = Date.now();

  const res = http.post(
    'https://api.example.com/v1/chat/completions',
    payload,
    {
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${__ENV.LLM_API_KEY}`,
      },
      timeout: '30s',
    }
  );

  const elapsed = Date.now() - startTime;

  // Track rate limit responses separately
  if (res.status === 429) {
    rateLimitHits.add(1);
    console.warn(`Rate limited at ${new Date().toISOString()}`);
    sleep(5); // back off to avoid cascading rate limits
    return;
  }

  // Track timeouts
  timeoutRate.add(res.status === 0 || elapsed > 29000);

  if (res.status === 200) {
    const body = JSON.parse(res.body);
    const completionTokens = body.usage?.completion_tokens || 0;
    const totalTime = elapsed / 1000; // seconds

    // Approximate TTFT (for accurate TTFT, use streaming with k6 WebSocket)
    ttft.add(elapsed * 0.15); // rough heuristic for non-streaming
    totalGenTime.add(elapsed);

    if (totalTime > 0 && completionTokens > 0) {
      tokensPerSecond.add(completionTokens / totalTime);
    }

    check(res, {
      'status is 200': (r) => r.status === 200,
      'response has content': () => body.choices?.[0]?.message?.content?.length > 0,
      'under token budget': () => body.usage?.total_tokens < 1000,
    });
  }

  sleep(Math.random() * 2 + 0.5); // think time between requests
}

Паттерн тестирования холодного старта

Serverless-деплои LLM (AWS Bedrock, self-hosted на Lambda/Cloud Run) демонстрируют штрафы за холодный старт, которые могут кардинально повлиять на пользовательский опыт:

// k6-cold-start-test.js
import http from 'k6/http';
import { Trend } from 'k6/metrics';
import { sleep } from 'k6';

const coldStartLatency = new Trend('cold_start_latency', true);
const warmLatency = new Trend('warm_latency', true);

export const options = {
  iterations: 10,
  vus: 1, // sequential to isolate cold starts
};

export default function () {
  // Cold start: wait long enough for the instance to scale down
  sleep(300); // 5 minutes idle -- adjust based on your provider's scale-down policy

  const coldRes = http.post('https://llm.example.com/v1/completions', JSON.stringify({
    prompt: "Hello", max_tokens: 5,
  }), { headers: { 'Content-Type': 'application/json' }, timeout: '60s' });

  coldStartLatency.add(coldRes.timings.duration);
  console.log(`Cold start: ${coldRes.timings.duration}ms`);

  // Warm requests: rapid fire while the instance is hot
  for (let i = 0; i < 5; i++) {
    const warmRes = http.post('https://llm.example.com/v1/completions', JSON.stringify({
      prompt: "Hello", max_tokens: 5,
    }), { headers: { 'Content-Type': 'application/json' }, timeout: '30s' });

    warmLatency.add(warmRes.timings.duration);
    sleep(1);
  }
}

Стратегии смягчения холодного старта

Стратегия Как работает Компромисс
Предварительно выделенная конкурентность Предварительный прогрев N экземпляров (AWS Lambda, Cloud Run min-instances) Стоимость: вы платите за простаивающую ёмкость
Keep-alive пинги Периодические health-check запросы предотвращают масштабирование до нуля Минимальная стоимость, добавляет сложность
Кеширование модели Хранение весов модели в памяти между вызовами Требует постоянного рантайма (не чистый serverless)
Edge-деплой Развёртывание меньших моделей на границе сети для инференса с низкой задержкой Ограниченные возможности модели

Тестирование лимитов запросов

Понимание поведения лимитов вашего провайдера критически важно для планирования ёмкости:

// k6-rate-limit-test.js
import http from 'k6/http';
import { Counter, Trend } from 'k6/metrics';

const rateLimits = new Counter('rate_limit_responses');
const responseStatus = new Counter('response_status');
const retryAfter = new Trend('retry_after_seconds');

export const options = {
  scenarios: {
    burst: {
      executor: 'constant-arrival-rate',
      rate: 100,       // deliberately exceed expected rate limit
      timeUnit: '1s',
      duration: '2m',
      preAllocatedVUs: 100,
      maxVUs: 200,
    },
  },
};

export default function () {
  const res = http.post('https://api.example.com/v1/chat/completions',
    JSON.stringify({
      model: "gpt-4o-mini",  // use cheapest model for rate limit testing
      messages: [{ role: "user", content: "Say hi" }],
      max_tokens: 5,
    }),
    { headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${__ENV.LLM_API_KEY}`,
    }}
  );

  responseStatus.add(1, { status: String(res.status) });

  if (res.status === 429) {
    rateLimits.add(1);
    const retryHeader = res.headers['Retry-After'];
    if (retryHeader) {
      retryAfter.add(parseFloat(retryHeader));
    }
  }
}

Нагрузочное тестирование с учётом стоимости

Нагрузочные тесты LLM стоят реальных денег. Планируйте ваш бюджет:

# estimate_load_test_cost.py
def estimate_cost(
    requests: int,
    avg_prompt_tokens: int = 200,
    avg_completion_tokens: int = 150,
    model: str = "gpt-4o",
) -> dict:
    """Estimate the cost of a load test run."""
    pricing = {
        "gpt-4o": {"input": 2.50 / 1_000_000, "output": 10.00 / 1_000_000},
        "gpt-4o-mini": {"input": 0.15 / 1_000_000, "output": 0.60 / 1_000_000},
        "claude-3-5-sonnet": {"input": 3.00 / 1_000_000, "output": 15.00 / 1_000_000},
    }

    if model not in pricing:
        return {"error": f"Unknown model: {model}"}

    p = pricing[model]
    input_cost = requests * avg_prompt_tokens * p["input"]
    output_cost = requests * avg_completion_tokens * p["output"]

    return {
        "model": model,
        "requests": requests,
        "estimated_input_cost": f"${input_cost:.2f}",
        "estimated_output_cost": f"${output_cost:.2f}",
        "estimated_total_cost": f"${input_cost + output_cost:.2f}",
    }

# Example: 1000 requests to GPT-4o
print(estimate_cost(1000, model="gpt-4o"))
# {'model': 'gpt-4o', 'requests': 1000,
#  'estimated_input_cost': '$0.50', 'estimated_output_cost': '$1.50',
#  'estimated_total_cost': '$2.00'}

Советы по оптимизации затрат на нагрузочное тестирование

  1. Используйте самую дешёвую модель для тестирования лимитов запросов и пропускной способности. Вам не нужен GPT-4o, чтобы проверить работу вашего лимитера. Используйте gpt-4o-mini.
  2. Минимизируйте max_tokens. Установите max_tokens: 5 для тестов, которые измеряют только задержку, а не качество вывода.
  3. Кешируйте где возможно. Если в вашей системе есть семантический кеш, проверьте его работу под нагрузкой, отправляя повторяющиеся запросы.
  4. Тестируйте короткими всплесками. Вместо 30-минутного устойчивого теста используйте 5-минутную рампу с агрессивным увеличением частоты для быстрого нахождения точки отказа.
  5. Бюджет на каждый тестовый прогон. Установите жёсткий потолок затрат (например, $10 на CI-прогон) и проектируйте тесты в рамках этого ограничения.

Интеграция с CI для нагрузочных тестов LLM

# .github/workflows/llm-performance.yml
name: LLM Performance Gate
on:
  push:
    branches: [main]
    paths:
      - 'src/ai/**'       # Only run when AI-related code changes
      - 'prompts/**'

jobs:
  llm-load-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run LLM load test
        uses: grafana/k6-action@v0.4.0
        with:
          filename: tests/performance/k6-llm-load-test.js
          flags: --out json=llm-results.json
        env:
          LLM_API_KEY: ${{ secrets.LLM_API_KEY }}

      - name: Check cost threshold
        run: |
          python scripts/check_llm_test_cost.py llm-results.json --max-cost 10.00

      - name: Upload results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: llm-load-test-results
          path: llm-results.json

Нагрузочное тестирование LLM требует смены парадигмы от «сколько запросов в секунду» к «как ведут себя стоимость, задержка и качество при росте конкурентности». Инструменты те же (k6, Locust), но метрики и ограничения принципиально другие.