Нагрузочное тестирование LLM: TTFT, ITL, p95, ошибки 429 и цена 1000 диалогов
Какие метрики нужны вместо «среднего времени ответа», как измерить их при стриминге через Locust и k6, как считать стоимость диалога и что делать с лимитами провайдера. С кодом сценария и формулами.
aa7b4a5
Содержание
Коротко
- Среднее время ответа для 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 и плавность, для пакетной обработки документов — общая пропускная способность.
Профили нагрузки
- Smoke. Один-два виртуальных пользователя, проверка, что сценарий и метрики работают.
- Нагрузка. Типовая ожидаемая конкурентность на 15–30 минут. Снимаем базу.
- Ступенчатый рост. Увеличиваем конкурентность шагами (5, 10, 20, 40 …) и смотрим, на каком шаге растут TTFT и p95 или появляются ошибки. Это и есть поиск точки деградации.
- Стресс. Выше ожидаемого пика: как система падает и как восстанавливается.
- Длительная стабильность. Несколько часов на умеренной нагрузке: утечки, деградация, накопление очередей.
Связь с конкурентностью полезно помнить по закону Литтла: число одновременных запросов примерно равно пропускной способности, умноженной на среднюю задержку. Если задержка растёт, а ограничение клиентов по конкурентности стоит, пропускная способность упадёт, не показав «ошибок».
Тестовые данные
Нагрузка на 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. В тесте важно:
- Считать 429 отдельно от прочих ошибок: это сигнал, что вы упёрлись в лимит, а не что сервис сломан.
- Проверять политику ретраев приложения: экспоненциальная задержка с джиттером, предел числа повторов, учёт
Retry-After. - Убедиться, что ретраи не раздувают нагрузку: бурный повтор тысяч запросов превращает перегрузку в шторм.
- Проверить таймауты стриминга: что видит пользователь, если поток замолчал на середине.
- Убедиться, что при деградации не включается молча более слабая модель без ведома продукта: качество под нагрузкой тоже метрика.
Отдельный сценарий — семантический кэш: он снижает стоимость и задержку, но может отдавать ответ на похожий, а не тот же вопрос. Проверяйте качество ответов из кэша на датасете.
Считаем стоимость
Цена запроса: (токены_входа × цена_входа + токены_выхода × цена_выхода) / 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 ответов в нагрузочном тесте, а не оценивайте на глаз.
Что в итоговом отчёте
- Описание стенда и версии: модель, провайдер, регион, дата, параметры.
- Профиль нагрузки и распределение длин промптов.
- Графики TTFT, ITL, p95 и пропускной способности от конкурентности.
- Точка деградации: при какой конкурентности p95 TTFT выходит за целевое значение.
- Доля ошибок и 429.
- Стоимость запроса и 1000 диалогов.
- Рекомендации: лимиты, ретраи, размер контекста, кэш, выбор модели.
Ограничения
Результаты нагрузочного теста относятся к конкретной модели, провайдеру, времени суток и нагрузке: общие облачные API делят ресурсы между клиентами, и цифры плавают. Повторяйте прогон в разное время и не путайте измерение клиента (ваша сеть) с работой сервера. Нагрузочные прогоны платных API стоят денег, поэтому ставьте бюджет и лимит на ключ заранее.
Что дальше
- Запустите smoke-прогон с двумя пользователями и убедитесь, что
ttftиe2eотображаются в статистике. - Составьте таблицу стоимости 1000 диалогов для вашего сценария.
- Автоматизируйте проверки качества и стоимости в CI: quality gate на pytest и DeepEval. Общую картину навыков смотрите в карте AI QA.

