«API тормозит» — самая бесполезная формулировка в баг-репорте. Латенси LLM-эндпоинта складывается из двух независимых величин, которые ведут себя по-разному и лечатся разными способами. Разбираем, что именно мерить, как это делать корректно и почему цифры из чужих бенчмарков не переносятся на вашу нагрузку.
Почему пинг ничего не говорит
Пинг до api.tokendock.cloud измеряет сетевое плечо: 20 мс или 60 мс до шлюза. Эта величина входит в общее время ответа, но её доля исчезающе мала.
Модель на 70 миллиардов параметров тратит на первый токен сотни миллисекунд машинного времени: нужно прогнать весь ваш промпт через веса, заполнить KV-кэш, дождаться своего места в батче на GPU. Сеть здесь — единицы процентов бюджета. Улучшать пинг, когда TTFT равен 500 мс, — это оптимизировать не то.
Ровно по той же причине бесполезен curl -w '%{time_total}' без разбивки: одно число смешивает установку соединения, prefill, генерацию и передачу — понять из него, что чинить, нельзя.
Три числа, которые описывают скорость
TTFT — time to first token
Время от отправки запроса до первого содержательного чанка в потоке. Это то, что пользователь воспринимает как отзывчивость: пока не появился первый символ, интерфейс выглядит зависшим.
TTFT определяется prefill-фазой и очередью на стороне провайдера. От длины ответа он не зависит совсем.
Tokens per second — скорость генерации
Сколько токенов приходит в секунду после первого. Определяется decode-фазой: каждый следующий токен требует полного прохода по весам модели, поэтому скорость упирается в пропускную способность памяти GPU, а не в сеть.
Считать нужно именно от первого токена, а не от начала запроса, иначе в метрику протечёт TTFT и обе величины перестанут что-либо значить.
Полное время ответа
Складывается по простой формуле:
total = TTFT + completion_tokens / tokens_per_second
Из формулы следует вывод, который меняет решения: на коротких ответах всё определяет TTFT, на длинных — скорость генерации. Если вы отдаёте пользователю подсказку в две строки, смотрите на TTFT. Если генерируете статью, TTFT можно игнорировать и следить только за токенами в секунду.
p50, p95 и почему среднее врёт
Распределение латенси у LLM-API не нормальное. У него длинный правый хвост: большинство запросов быстрые, но небольшая часть попадает в переполненный батч, на холодный узел или в момент перебалансировки — и ждёт в разы дольше.
Среднее арифметическое такой хвост размазывает. Двадцать запросов по 300 мс и один на 4 секунды дают среднее 476 мс — число, которое не описывает ни один реальный запрос.
- p50 (медиана) — типичный запрос. Отвечает на вопрос «как обычно».
- p95 — граница, ниже которой укладываются 95% запросов. Отвечает на вопрос «как плохо бывает регулярно».
- p99 — редкие, но заметные выбросы. Имеет смысл на объёмах от десятков тысяч запросов в сутки.
Проектировать таймауты и SLA нужно по p95. Если p50 равен 310 мс, а p95 — 840 мс, таймаут в 500 мс будет обрывать каждый десятый нормальный запрос.
Разница между p50 и p95 важнее их абсолютных значений. Разрыв втрое означает, что каждый двадцатый запрос ждёт заметно дольше остальных. Для интерактивного интерфейса предсказуемость ценнее, чем выигрыш в медиане.
Как мерить самому
Чужие бенчмарки не переносятся: у вас другая длина промпта, другой регион, другая модель и другой HTTP-клиент. Меряйте на своём профиле нагрузки.
import json
import os
import statistics
import time
import requests
URL = "https://api.tokendock.cloud/v1/chat/completions"
HEADERS = {"Authorization": f"Bearer {os.environ['TOKENDOCK_API_KEY']}"}
SESSION = requests.Session() # keep-alive: TLS-хендшейк не должен попасть в замер
def measure(model: str, prompt: str) -> dict:
body = {
"model": model,
"stream": True,
"stream_options": {"include_usage": True},
"max_tokens": 600,
"messages": [{"role": "user", "content": prompt}],
}
started = time.perf_counter()
ttft = None
completion = 0
with SESSION.post(URL, json=body, headers=HEADERS, stream=True) as response:
response.raise_for_status()
for line in response.iter_lines():
if not line or not line.startswith(b"data: "):
continue
payload = line[6:]
if payload == b"[DONE]":
break
chunk = json.loads(payload)
if chunk.get("usage"):
completion = chunk["usage"]["completion_tokens"]
choices = chunk.get("choices") or []
if choices and choices[0]["delta"].get("content") and ttft is None:
ttft = time.perf_counter() - started
total = time.perf_counter() - started
return {"ttft": ttft, "total": total, "tokens": completion}
def percentile(values: list[float], q: float) -> float:
ordered = sorted(values)
return ordered[min(len(ordered) - 1, int(len(ordered) * q))]
runs = [measure("openai/gpt-4.1-mini", "Опиши устройство HTTP/2 в пяти абзацах.")
for _ in range(50)]
ttfts = [r["ttft"] for r in runs if r["ttft"]]
speeds = [r["tokens"] / (r["total"] - r["ttft"]) for r in runs if r["tokens"] and r["ttft"]]
print(f"TTFT p50 {percentile(ttfts, 0.5) * 1000:.0f} мс p95 {percentile(ttfts, 0.95) * 1000:.0f} мс")
print(f"Speed медиана {statistics.median(speeds):.1f} ток/с")
Правила, без которых замер не имеет смысла:
- Прогревайте соединение. Первый запрос включает DNS, TCP и TLS — выбрасывайте его из выборки. Переиспользуйте сессию, как в примере выше.
- Не меньше 50 запросов. На двадцати p95 — это фактически один случайный запрос.
- Меряйте оттуда, где стоит прод. Ноутбук по Wi-Fi добавит собственный хвост, который вы примете за латенси API.
- Фиксируйте промпт и
max_tokens. Иначе сравниваете не модели, а разные объёмы работы. - Разносите замеры по времени. Пятьдесят запросов подряд в одну минуту описывают одну минуту, а не сутки. Гоняйте раз в пять минут и стройте распределение за день.
- Не бейте параллельно. Конкурентные запросы упрутся в ваш собственный лимит RPM и покажут не латенси провайдера, а очередь у вас — см. лимиты.
Что влияет на TTFT
- Длина промпта. Prefill линейно растёт с числом входных токенов. Промпт на 30 000 токенов добавляет к TTFT сотни миллисекунд — сокращение истории диалога улучшает не только счёт, но и отзывчивость.
- Размер модели. Флагман на сотни миллиардов параметров стартует медленнее компактной модели, даже когда обе стоят на одинаковом железе.
- Reasoning. У моделей с рассуждениями первый видимый токен появляется после скрытой фазы размышления. Формально это тот же TTFT, но по ощущениям — совсем другая величина.
- Очередь провайдера. Пиковые часы, чужая нагрузка на том же кластере, перебалансировка батчей. Это основная причина расхождения p50 и p95.
- Холодный старт. Если модель давно не запрашивали, первый запрос ждёт загрузки весов в память. У редких моделей это заметно, у популярных почти не встречается.
- Ваш собственный стек. Самая частая находка при разборе жалоб на «медленный стриминг»: буферизация на обратном прокси. Nginx с включённым
proxy_bufferingкопит ответ и отдаёт его пачкой, превращая поток в обычный ответ. TTFT при этом становится равен полному времени генерации, хотя API работает штатно.
Что делать с замерами
Порядок работы простой. Снять базовую линию на своём профиле нагрузки. Записать p50 и p95 отдельно для TTFT и для скорости генерации. Настроить таймауты по p95 с запасом, а для стриминга считать таймаут по паузе между чанками, а не по общей длительности ответа. И держать метрику в мониторинге постоянно — деградация видна по расхождению p95 и p50 задолго до того, как начнут сыпаться ошибки.
Форматы потока и разбор чанков — в разделе streaming.