Нагрузочное тестирование 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'}
Советы по оптимизации затрат на нагрузочное тестирование
- Используйте самую дешёвую модель для тестирования лимитов запросов и пропускной способности. Вам не нужен GPT-4o, чтобы проверить работу вашего лимитера. Используйте gpt-4o-mini.
- Минимизируйте max_tokens. Установите
max_tokens: 5для тестов, которые измеряют только задержку, а не качество вывода. - Кешируйте где возможно. Если в вашей системе есть семантический кеш, проверьте его работу под нагрузкой, отправляя повторяющиеся запросы.
- Тестируйте короткими всплесками. Вместо 30-минутного устойчивого теста используйте 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), но метрики и ограничения принципиально другие.