Регистрация
Все статьи
Надёжность 29.07.2026 7 мин

TTFT и токены в секунду: что мерить у LLM-API

Почему пинг не показатель, чем TTFT отличается от tokens/s, зачем нужны p50 и p95. Скрипт для замера на своём трафике и разбор факторов, которые портят время до первого токена.

«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.

Проверьте на своей задаче

Ключ выдаётся сразу после регистрации — базовый URL и баланс уже в консоли.