Р. Аллахвердиев

Нагрузочное тестирование LLM: TTFT, ITL, p95, ошибки 429 и цена 1000 диалогов

Какие метрики нужны вместо «среднего времени ответа», как измерить их при стриминге через Locust и k6, как считать стоимость диалога и что делать с лимитами провайдера. С кодом сценария и формулами.

опубликовано · 11 минaa7b4a5
Обложка: Нагрузочное тестирование LLM: TTFT, ITL, p95, ошибки 429 и цена 1000 диалогов
Содержание

Коротко

  • Среднее время ответа для LLM почти ничего не говорит. Ответ приходит потоком, поэтому измеряют TTFT (до первого токена), межтокенную задержку (ITL/TPOT), E2E-время и перцентили.
  • Пропускную способность считают в запросах и в токенах в секунду, а считать её нужно при росте конкурентности: точка, где TTFT и p95 начинают расти, и есть граница нагрузки.
  • Лимиты провайдера (HTTP 429) — часть сценария, а не авария. Тест должен показывать, как система ведёт себя при ретраях и backoff.
  • Стоимость тестируется так же, как скорость: цена запроса и цена 1000 диалогов зависят от токенов на входе и выходе.
  • Инструменты: Locust умеет считать токены потока, у k6 из коробки есть время до первого байта; для self-hosted моделей есть специализированные бенчмарки.

Почему «среднее время ответа» не подходит

В обычном API ответ либо пришёл, либо нет. У LLM пользователь видит текст, который «печатается». Он чувствует два разных времени: сколько ждал до начала ответа и насколько плавно идёт текст. Среднее по запросам смешивает короткие и длинные ответы и скрывает хвосты. Хвосты же определяют впечатление: если один из двадцати запросов ждёт полминуты, это заметят все.

Метрики производительности

Метрика Что это Формула или способ
TTFT время до первого токена момент первого чанка минус момент отправки запроса
ITL задержка между токенами разность времён соседних токенов
TPOT среднее время на выходной токен после первого (E2E - TTFT) / (число_токенов - 1)
E2E latency полное время ответа конец потока минус начало запроса
p50 / p95 / p99 перцентили любой метрики сортировка значений, не среднее
RPS запросов в секунду успешные запросы / время
Токенов/с пропускная способность по выходу сумма выходных токенов / время
Error rate доля ошибок включая 429, 5xx, таймауты

Что влияет на цифры под капотом (без глубокой теории): обработка входного промпта (prefill) определяет TTFT и растёт с длиной контекста, а генерация токенов (decode) определяет TPOT. Поэтому в RAG с длинным контекстом TTFT хуже, а в генерации длинных текстов хуже E2E. Для чата важнее TTFT и плавность, для пакетной обработки документов — общая пропускная способность.

Профили нагрузки

  1. Smoke. Один-два виртуальных пользователя, проверка, что сценарий и метрики работают.
  2. Нагрузка. Типовая ожидаемая конкурентность на 15–30 минут. Снимаем базу.
  3. Ступенчатый рост. Увеличиваем конкурентность шагами (5, 10, 20, 40 …) и смотрим, на каком шаге растут TTFT и p95 или появляются ошибки. Это и есть поиск точки деградации.
  4. Стресс. Выше ожидаемого пика: как система падает и как восстанавливается.
  5. Длительная стабильность. Несколько часов на умеренной нагрузке: утечки, деградация, накопление очередей.

Связь с конкурентностью полезно помнить по закону Литтла: число одновременных запросов примерно равно пропускной способности, умноженной на среднюю задержку. Если задержка растёт, а ограничение клиентов по конкурентности стоит, пропускная способность упадёт, не показав «ошибок».

Тестовые данные

Нагрузка на LLM зависит от длины запросов и ответов. Если в тесте все промпты по 20 токенов, а в проде по 2000, результат будет бессмысленным. Возьмите распределение длин из реальных логов (или из golden-датасета), включая «хвост» длинных запросов. Следите и за ограничением длины ответа (max_tokens): от него напрямую зависит E2E и стоимость.

Locust и стриминг

Locust пишет сценарии на Python. Для стриминга по SSE мы читаем поток построчно, замеряем время первого токена и сами отправляем метрики в статистику. Пример для OpenAI-совместимого API (адрес, ключ и модель подставьте свои):

import json, os, time
from locust import HttpUser, task, between

MODEL = os.environ.get("LLM_MODEL", "your-model")
KEY = os.environ.get("LLM_KEY", "")

class ChatUser(HttpUser):
    wait_time = between(1, 3)

    @task
    def chat(self):
        payload = {
            "model": MODEL,
            "stream": True,
            "max_tokens": 300,
            "messages": [{"role": "user", "content": "Объясни, что такое RAG, в 5 предложениях."}],
        }
        headers = {"Authorization": f"Bearer {KEY}"}
        start = time.perf_counter()
        ttft = None
        chunks = 0
        error = None
        try:
            with self.client.post("/v1/chat/completions", json=payload, headers=headers,
                                  stream=True, catch_response=True, name="chat (headers)") as resp:
                if resp.status_code != 200:
                    error = f"HTTP {resp.status_code}"
                    resp.failure(error)
                else:
                    for raw in resp.iter_lines():
                        if not raw or not raw.startswith(b"data: "):
                            continue
                        data = raw[6:]
                        if data == b"[DONE]":
                            break
                        delta = json.loads(data)["choices"][0].get("delta", {}).get("content")
                        if delta:
                            chunks += 1
                            if ttft is None:
                                ttft = (time.perf_counter() - start) * 1000
                    resp.success()
        except Exception as e:  # таймаут, обрыв соединения
            error = repr(e)

        total = (time.perf_counter() - start) * 1000
        events = self.environment.events.request
        if ttft is not None:
            events.fire(request_type="LLM", name="ttft", response_time=ttft,
                        response_length=0, exception=None, context={})
        events.fire(request_type="LLM", name="e2e", response_time=total,
                    response_length=chunks, exception=Exception(error) if error else None, context={})

Пояснения:

  • Строка «chat (headers)» в статистике — время до получения заголовков ответа, а метрики ttft и e2e мы добавляем сами через событие request.
  • Число чанков примерно равно числу токенов, но не точно. Для точного счёта запрашивайте usage в потоке, если API поддерживает (у OpenAI-совместимых серверов это обычно опция stream_options с include_usage), и проверьте в документации вашего сервера.
  • Запуск: locust -f locustfile.py --host https://your-llm-host, затем задавайте число пользователей и скорость роста. Для CI есть режим --headless.
  • Для ступенчатой нагрузки в Locust используют класс LoadTestShape.

Перцентили Locust выводит в таблице и в CSV, p95 по ttft и e2e ищите там.

k6

k6 хорош для простых HTTP-сценариев и порогов в CI. Нужно знать ограничение: стандартный http.post возвращает ответ после получения тела, поэтому покадровые метрики потока из коробки недоступны. Зато время до первого байта есть всегда: res.timings.waiting. Для чата с одним ответом это хорошее приближение TTFT:

import http from 'k6/http';
import { check } from 'k6';
import { Trend } from 'k6/metrics';

const ttfb = new Trend('llm_ttfb', true);

export const options = {
  stages: [{ duration: '2m', target: 10 }, { duration: '5m', target: 10 }],
  thresholds: { llm_ttfb: ['p(95)<2000'], http_req_failed: ['rate<0.02'] },
};

export default function () {
  const res = http.post(`${__ENV.HOST}/v1/chat/completions`, JSON.stringify({
    model: __ENV.MODEL, stream: true, max_tokens: 300,
    messages: [{ role: 'user', content: 'Объясни, что такое RAG, в 5 предложениях.' }],
  }), { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${__ENV.KEY}` } });
  check(res, { 'status 200': (r) => r.status === 200 });
  ttfb.add(res.timings.waiting);
}

Порог p(95)<2000 — пример, который показывает синтаксис; свои пороги берите из требований продукта. Если нужны ITL и токены в секунду на потоке, используйте Locust или расширения k6 для SSE и сверьтесь с их документацией. Для self-hosted моделей есть и специализированные инструменты нагрузки (например, GuideLLM и встроенные бенчмарки vLLM), подробности смотрите в их репозиториях.

Лимиты провайдера и ошибки 429

Облачные API ограничивают запросы и токены в минуту. При превышении приходит 429, часто с заголовком Retry-After. В тесте важно:

  1. Считать 429 отдельно от прочих ошибок: это сигнал, что вы упёрлись в лимит, а не что сервис сломан.
  2. Проверять политику ретраев приложения: экспоненциальная задержка с джиттером, предел числа повторов, учёт Retry-After.
  3. Убедиться, что ретраи не раздувают нагрузку: бурный повтор тысяч запросов превращает перегрузку в шторм.
  4. Проверить таймауты стриминга: что видит пользователь, если поток замолчал на середине.
  5. Убедиться, что при деградации не включается молча более слабая модель без ведома продукта: качество под нагрузкой тоже метрика.

Отдельный сценарий — семантический кэш: он снижает стоимость и задержку, но может отдавать ответ на похожий, а не тот же вопрос. Проверяйте качество ответов из кэша на датасете.

Считаем стоимость

Цена запроса: (токены_входа × цена_входа + токены_выхода × цена_выхода) / 1 000 000, если цена задана за миллион токенов. Для диалога складываются все запросы, причём история растёт: входных токенов на каждом следующем ходе больше.

Пример с иллюстративными цифрами (цены и объёмы выдуманы для демонстрации): вход — 0,5 у.е. за миллион токенов, выход — 2 у.е. за миллион. Диалог из 5 запросов, в среднем 1500 токенов на входе и 300 на выходе.

  • Запрос: 1500 × 0,5 / 1e6 + 300 × 2 / 1e6 = 0,00075 + 0,0006 = 0,00135 у.е.
  • Диалог: 5 × 0,00135 = 0,00675 у.е.
  • 1000 диалогов: около 6,75 у.е.

Реальные цены берите с сайта провайдера на дату расчёта и записывайте дату в отчёт. Расчёт надо перепроверять после смены модели, промпта или схемы RAG: добавили три фрагмента в контекст — и цена входа выросла кратно. Реальные токены берите из поля usage ответов в нагрузочном тесте, а не оценивайте на глаз.

Что в итоговом отчёте

  1. Описание стенда и версии: модель, провайдер, регион, дата, параметры.
  2. Профиль нагрузки и распределение длин промптов.
  3. Графики TTFT, ITL, p95 и пропускной способности от конкурентности.
  4. Точка деградации: при какой конкурентности p95 TTFT выходит за целевое значение.
  5. Доля ошибок и 429.
  6. Стоимость запроса и 1000 диалогов.
  7. Рекомендации: лимиты, ретраи, размер контекста, кэш, выбор модели.

Ограничения

Результаты нагрузочного теста относятся к конкретной модели, провайдеру, времени суток и нагрузке: общие облачные API делят ресурсы между клиентами, и цифры плавают. Повторяйте прогон в разное время и не путайте измерение клиента (ваша сеть) с работой сервера. Нагрузочные прогоны платных API стоят денег, поэтому ставьте бюджет и лимит на ключ заранее.

Что дальше

  • Запустите smoke-прогон с двумя пользователями и убедитесь, что ttft и e2e отображаются в статистике.
  • Составьте таблицу стоимости 1000 диалогов для вашего сценария.
  • Автоматизируйте проверки качества и стоимости в CI: quality gate на pytest и DeepEval. Общую картину навыков смотрите в карте AI QA.