Как измерить p95 задержку в LLM API
Разбираем, как измерить p95 задержку LLM API по сети, шлюзу, очереди GPU и генерации с помощью k6, OpenTelemetry и сквозного trace.

У одной медленной генерации есть как минимум четыре возможных владельца: сеть, API-шлюз, очередь перед GPU и сама модель. Если смотреть только на http_req_duration, все они превращаются в одну p95, после чего команда начинает спорить по ощущениям. Сетевики винят inference, ML-инженеры винят прокси, а график не способен рассудить их.
Рабочее измерение строится на двух наблюдениях одного запроса. k6 фиксирует время снаружи, а OpenTelemetry spans фиксируют работу внутри сервера. Их связывает общий trace ID. Вычитать нужно длительности, а не абсолютные отметки времени: часы генератора нагрузки и сервера почти никогда не синхронизированы настолько хорошо, чтобы по ним разбирать десятки миллисекунд.
Одна p95 скрывает четыре разные задержки
Разложите запрос до запуска теста, иначе вы подгоните названия фаз под уже увиденный график. Для обычного HTTP-ответа клиентское время удобно представить так:
client_total = client_transport + gateway + gpu_queue + generation + response_transfer
server_total = gateway + gpu_queue + generation
network_envelope ≈ client_total - server_total
client_transport включает ожидание свободного соединения у клиента, DNS, TCP, TLS и отправку тела. gateway включает аутентификацию, rate limit, маскирование данных, выбор маршрута, преобразование запроса и обращение к провайдеру. gpu_queue начинается, когда запрос готов к исполнению, но вычислительный слот еще не получен. generation начинается при фактическом старте inference и заканчивается последним токеном или отменой.
Последняя строка формулы называется сетевым конвертом намеренно. Разность включает путь туда и обратно, балансировщики вне серверного root span, передачу ответа и небольшие расходы клиента. Это не чистый RTT. Если команда назовет разность «сетью», она однажды начнет лечить канал связи, хотя задержку добавляет компрессия ответа после закрытия span.
Для потокового ответа нужны две величины. Время до первого байта, а для LLM практически время до первого токена, отвечает на вопрос о реакции системы. Полная длительность отвечает на вопрос, когда пользователь получил весь результат. Один и тот же запрос может иметь нормальный TTFT и долгую генерацию, либо плохой TTFT и высокую скорость последующих токенов. Смешивать эти случаи в одном SLO нельзя.
Различайте очередь приложения и очередь GPU. Ожидание в пуле HTTP-соединений, семафоре шлюза или брокере относится к тому компоненту, который держит запрос. GPU queue начинается только после того, как планировщик inference принял работу. Это различие определяет владельца инцидента и способ исправления: больше реплик шлюза не освободит вычислительные слоты модели.
Сквозной trace должен начинаться в k6
Каждый запрос нагрузки должен нести уникальный W3C traceparent, тогда запись k6 можно найти среди серверных spans без поиска по времени и URL. Рекомендация W3C Trace Context задает формат version-trace-id-parent-id-flags; trace ID содержит 32 шестнадцатеричных символа, parent ID содержит 16. Нулевые идентификаторы недопустимы.
В тесте не нужен полноценный tracing SDK. Достаточно сформировать корректный заголовок и сохранить trace ID как тег пользовательских метрик или строку диагностического лога. Следующий фрагмент дает отдельную область идентификаторов каждому VU и каждой итерации:
import exec from 'k6/execution';
function hex(value, width) {
return Math.floor(value).toString(16).padStart(width, '0').slice(-width);
}
export function traceContext() {
const vu = exec.vu.idInTest;
const iteration = exec.scenario.iterationInTest + 1;
const traceId = hex(vu, 16) + hex(iteration, 16);
const parentId = hex(vu * 1000000 + iteration, 16);
return {
traceId,
header: `00-${traceId}-${parentId}-01`,
};
}
Такой генератор годится для контролируемого теста, пока значения остаются в безопасном диапазоне JavaScript Number. Для многопроцессного распределенного запуска добавьте идентификатор экземпляра генератора в старшую часть trace ID или используйте криптографический генератор расширения k6. Коллизия опаснее пропущенного trace: она склеит две независимые истории и испортит квантили фаз.
Шлюз обязан принять входной контекст, создать SERVER span и передать обновленный контекст дальше. Если промежуточный прокси удаляет traceparent, цепочка разрывается. Проверяйте это отдельным одиночным запросом до нагрузки: один trace должен содержать клиентский parent, span шлюза, ожидание очереди и генерацию. Флаг 01 просит записать trace, но спецификация W3C прямо не гарантирует сохранение. Поэтому политика sampling на сервере тоже входит в тестовую конфигурацию.
Не кладите trace ID как тег во все стандартные метрики k6. Уникальный тег на каждый запрос создает огромную кардинальность и может перегрузить хранилище. Для корреляции достаточно печатать ID только у медленных или ошибочных запросов, а метрики агрегировать по сценарию, модели, региону и классу результата.
k6 должен отдельно мерить первый байт и полный ответ
Grafana k6 описывает Response.timings.waiting как фазу ожидания ответа после отправки запроса, а duration как сумму отправки, ожидания и получения. Для непотокового API waiting близок ко времени до первого байта. Для SSE сначала подтвердите поведение вашей версии k6 маленьким тестом: некоторые клиентские пути буферизуют тело, и красивое имя метрики не отменяет фактическую реализацию.
Ниже находится минимальный тест с пользовательскими Trend. Он отправляет фиксированный prompt, чтобы длина входа не гуляла, и записывает клиентский конверт, TTFT и полную длительность. URL и токен приходят из окружения, а секрет не попадает в код или теги.
import http from 'k6/http';
import { check } from 'k6';
import { Trend, Counter } from 'k6/metrics';
import { traceContext } from './trace.js';
const ttft = new Trend('llm_ttft_ms', true);
const total = new Trend('llm_total_ms', true);
const transport = new Trend('llm_transport_setup_ms', true);
const failures = new Counter('llm_failures');
export const options = {
scenarios: {
steady: {
executor: 'constant-arrival-rate',
rate: 8,
timeUnit: '1s',
duration: '10m',
preAllocatedVUs: 40,
maxVUs: 120,
},
},
thresholds: {
'llm_ttft_ms{scenario:steady}': ['p(95)<1200'],
'llm_total_ms{scenario:steady}': ['p(95)<6000'],
'llm_failures{scenario:steady}': ['count<5'],
dropped_iterations: ['count==0'],
},
};
export default function () {
const trace = traceContext();
const payload = JSON.stringify({
model: __ENV.MODEL,
messages: [{ role: 'user', content: 'Объясни хеш-таблицу в 120 словах.' }],
temperature: 0,
max_tokens: 180,
stream: false,
});
const res = http.post(`${__ENV.BASE_URL}/v1/chat/completions`, payload, {
headers: {
Authorization: `Bearer ${__ENV.API_TOKEN}`,
'Content-Type': 'application/json',
traceparent: trace.header,
},
tags: { endpoint: 'chat', model: __ENV.MODEL },
timeout: '30s',
});
ttft.add(res.timings.waiting, { model: __ENV.MODEL });
total.add(res.timings.duration, { model: __ENV.MODEL });
transport.add(
res.timings.blocked + res.timings.connecting + res.timings.tls_handshaking,
{ model: __ENV.MODEL },
);
const ok = check(res, {
'status 200': (r) => r.status === 200,
'response has id': (r) => Boolean(r.json('id')),
});
if (!ok) {
failures.add(1);
console.error(JSON.stringify({ trace_id: trace.traceId, status: res.status }));
}
}
Этот вариант намеренно начинает с stream: false: он проверяет корреляцию и фазовые бюджеты без неопределенности SSE-клиента. Для настоящего streaming SLO используйте клиент, который фиксирует монотонную отметку при получении первого события с токеном, а не только заголовков HTTP. Можно оставить k6 генератором нагрузки, а небольшой совместимый клиент запустить как отдельный низкочастотный probe. Его результаты не смешивают с основной серией, пока методики не сверены на одинаковых запросах.
Не принимайте http_req_waiting за серверную генерацию. Метрика видит все до первого байта: путь к серверу, обработку шлюза, очередь и начало inference. Только серверные spans способны разделить эти части.
Серверный trace обязан повторять путь запроса
Root span шлюза должен охватывать запрос с момента принятия до завершения записи ответа. Внутри нужны spans по границам ответственности, а не по каждой функции. Практичная схема выглядит так:
POST /v1/chat/completions SERVER
auth.check INTERNAL
request.prepare INTERNAL
route.select INTERNAL
inference.acquire_slot INTERNAL llm.phase=queue
inference.generate CLIENT llm.phase=generation
response.write INTERNAL
Если шлюз вызывает внешний inference API, inference.generate имеет kind CLIENT и заканчивается после чтения ответа. Если модель работает в том же процессе, подойдет INTERNAL. OpenTelemetry Semantic Conventions задают общие имена для HTTP и GenAI атрибутов, но локальная очередь конкретного планировщика остается вашей операционной деталью. Не маскируйте ее одним span вызова модели: именно это и лишает trace диагностической ценности.
Записывайте длительность span монотонными часами SDK. На inference.acquire_slot добавьте низкокардинальные атрибуты llm.model, llm.pool, llm.priority и итог llm.queue.outcome. На генерации нужны модель, лимит выходных токенов, фактическое число токенов, причина завершения и признак streaming. Не помещайте prompt, полный ответ, API-ключ, email или индивидуальный request ID в атрибуты, которые индексирует backend.
Отдельно определите границу очереди в коде. Например, queue_start ставится после валидации и выбора пула, а generation_start вызывается ровно в момент выдачи слота планировщиком. Если провайдер возвращает только общий ответ и не раскрывает очередь, честно называйте span provider.request. Вы не можете восстановить внутреннюю GPU queue вычитанием. Для такой ветки бюджет ограничивается шлюзом, внешним вызовом и клиентским конвертом.
Счетчики токенов помогают нормализовать генерацию. Полная p95 растет вместе с длиной ответа даже у здоровой модели, поэтому рядом смотрят generation_ms_per_output_token и TTFT. Нулевое или очень малое число токенов при ошибке нельзя делить как обычный результат; держите ошибки в отдельном классе.
Разность длительностей дает сетевой конверт
Сопоставьте запись k6 и root span по trace ID, затем считайте network_envelope_ms = k6_duration_ms - server_root_duration_ms. Отрицательная разность означает ошибку границ, единиц или корреляции. Она не означает, что сеть ускорила запрос.
Абсолютные start_time двух машин для формулы не нужны. NTP может держать часы достаточно близко для журналов, но дрейф и ступенчатая коррекция легко превосходят маленький сетевой бюджет. Длительность каждого наблюдателя берется с его локальных монотонных часов, поэтому сравнение длительностей устойчивее. Если root span заканчивается до полной отправки тела, добавьте response.write и растяните root до фактического завершения записи.
Для потокового ответа стройте две пары. Клиентский TTFT сравнивайте с серверным временем от приема запроса до записи первого токена. Полную клиентскую длительность сравнивайте с root span до закрытия потока. Тогда остатки обозначают разные вещи: первый включает путь запроса и первого чанка, второй еще включает передачу остальных чанков и клиентское чтение.
Агрегировать нужно после join на уровне запроса, а не вычитать две независимые p95. В общем случае p95(A) - p95(B) не равно p95(A - B): в хвост каждого распределения попадают разные запросы. Сначала получите разность для каждой совпавшей пары, потом вычислите p50, p95 и p99 нового ряда. Рядом публикуйте долю совпавших traces. Если join покрыл 62% запросов, аккуратная p95 остатка мало что доказывает.
Сверка одного запроса должна давать понятный отчет:
trace_id=0000000000000007000000000000012f
k6_total_ms=1842
server_root_ms=1691
network_envelope_ms=151
gateway_ms=37
gpu_queue_ms=428
generation_ms=1210
unattributed_server_ms=16
unattributed_server_ms показывает разницу root span и суммы выбранных дочерних фаз. Небольшой остаток нормален: между spans выполняется код. Растущий остаток означает потерянную фазу или неверные границы, и его нельзя молча записывать в сеть.
Пороги задают бюджет, а не истину
Начните с пользовательского SLO, затем раздайте время компонентам. Порог не следует копировать из чужой статьи: модель, длина ответа, регион, concurrency и тип канала меняют распределение. Для сервиса с целью TTFT p95 не выше 1200 мс стартовый бюджет может быть таким: сетевой конверт 120 мс, шлюз 60 мс, GPU queue 250 мс, время до первого токена внутри генерации 770 мс. Это пример арифметики, а не отраслевой норматив.
У каждого бюджета должны быть условие и окно. Фраза «queue p95 меньше 250 мс» неполна без модели, региона, приоритета, диапазона входных токенов, заданной интенсивности и хотя бы десятиминутного устойчивого окна. Холодный старт проверяют отдельным сценарием. Иначе редкие загрузки весов смешаются с рабочей очередью и команда оптимизирует не тот режим.
Клиентские thresholds живут в k6, а серверные обычно вычисляет telemetry backend или отдельная проверка после теста. Полезный набор ворот включает:
network_envelope_ms p95 < 120только для совпавших traces;gateway_ms p95 < 60без ожидания внешнего провайдера;gpu_queue_ms p95 < 250для конкретного пула и класса запросов;ttft_ms p95 < 1200иtotal_ms p95 < 6000при фиксированном профиле токенов;- долю ошибок, dropped iterations и долю успешно сопоставленных traces.
Не давайте быстрой ошибке улучшать latency. Считайте квантили успешных ответов отдельно, а error rate держите самостоятельным воротом. То же относится к отменам клиента: отмененный через секунду запрос не должен выглядеть как быстрая генерация.
Порог сети стоит разбить на setup и остаток. blocked + connecting + tls_handshaking показывает проблемы с пулом соединений и установкой TLS, а paired envelope включает передачу и внешние балансировщики. Если setup вырос, проверьте reuse соединений и лимиты VU. Если setup стабилен, а envelope растет вместе с размером ответа, подозревайте канал или буферизацию, а не GPU.
Открытая модель нагрузки показывает очередь честнее
Для поиска точки насыщения задавайте скорость прихода запросов независимо от времени ответа. constant-arrival-rate в k6 запускает итерации по расписанию; медленный ответ требует больше VU, но не уменьшает заданную интенсивность. Закрытая модель с фиксированным числом VU сама снижает arrival rate при росте latency и может спрятать начало очереди.
dropped_iterations здесь является частью результата. Если генератор не смог начать запланированные итерации из-за нехватки VU, тест не создал обещанную нагрузку. Нельзя показывать серверную p95 для 8 RPS, когда фактический поток просел до 5 RPS. Сначала увеличьте preAllocatedVUs и maxVUs либо уменьшите rate до достижимого значения.
Проведите минимум два разных прогона. Устойчивый прогон на ожидаемой рабочей интенсивности проверяет SLO. Ступенчатый прогон повышает arrival rate и находит колено: очередь начинает расти быстрее, чем utilization, а throughput перестает следовать входному потоку. Не объединяйте их квантили, потому что они отвечают на разные вопросы.
Зафиксируйте распределение входных и выходных токенов. Один короткий prompt удобен для сравнения инфраструктуры, но плохо представляет продакшен. Для реалистичного профиля подготовьте несколько бакетов длины и выдавайте их в заданной пропорции. Не тегируйте каждую точную длину; используйте диапазоны вроде input_0_512 и output_129_256, иначе кардинальность снова станет отдельной аварией.
Параллелизм и RPS тоже не взаимозаменяемы. Два запроса в секунду с минутной генерацией удерживают много слотов, а тот же RPS с коротким ответом почти не создает очередь. В отчете рядом с arrival rate нужны активные запросы, токены в секунду, batch size планировщика и занятость GPU. Одна загрузка GPU без длины очереди не говорит, справляется ли система с пользовательским ожиданием.
Форма графика указывает на владельца задержки
Смотрите на совместное движение фаз, а не на самую высокую линию. Сетевой конверт, gateway, queue и generation имеют разные причины и по-разному реагируют на рост нагрузки.
Сопоставляйте симптом, вероятную причину и первую проверку:
- Растут connect и TLS, серверные spans стабильны | генератор открывает новые соединения или уперся в локальный ресурс | reuse соединений, лимиты сокетов, CPU генератора.
- Растет gateway, queue стабильна | rate limit, сериализация, маскирование или нехватка реплик шлюза | дочерние spans шлюза и CPU.
- Queue растет, generation на токен стабильна | пул inference насыщен | concurrency, batch policy, длина очереди.
- Generation на токен растет вместе с batch | слишком агрессивный batch или дефицит памяти | batch size, память GPU, профиль токенов.
- TTFT плохой, total почти не меняется | ожидание до старта выросло, а вывод короткий | queue и prefill отдельно.
- Client total растет, root span нет | передача ответа или участок вне root span | граница root, размер тела, балансировщик |
Рассмотрим сбой, который часто вводит в заблуждение. При 6 RPS p95 клиента равна 1,4 с, при 9 RPS становится 3,8 с. Время генерации остается около 1,1 с, gateway занимает 40 мс, а queue вырастает со 180 мс до 2,5 с. Добавление таймаута на шлюзе лишь превратит медленные успешные ответы в быстрые ошибки. Исправление находится в емкости пула, политике batch, лимите выходных токенов или admission control.
Другой случай выглядит похоже на графике клиента: p95 выросла на 500 мс, но все серверные фазы прежние. При этом tls_handshaking появляется почти в каждом запросе. Значит, тест перестал переиспользовать соединения или балансировщик закрыл keep-alive. Масштабирование GPU в таком случае дорого и бесполезно.
Проверяйте гипотезу контролируемым изменением. Уменьшите max tokens, удерживая arrival rate, и посмотрите, сокращается ли generation и вслед за ней queue. Запустите генератор ближе к серверу, не меняя сервер, и проверьте сетевой конверт. Меняйте один фактор за прогон, иначе trace покажет состав задержки, но не докажет причину изменения.
Телеметрия не должна менять измеряемую систему
Полная запись каждого span при нагрузке может перегрузить Collector, сеть экспорта или backend. Тогда измерительный контур добавляет задержку и одновременно теряет самые интересные traces. До теста следите за очередью экспортера, dropped spans, CPU Collector и временем batch export.
Head sampling легко настроить, но он принимает решение в начале и не знает, станет ли запрос медленным. Документация OpenTelemetry по sampling отмечает это ограничение и предлагает tail sampling, который рассматривает готовые spans trace. Для нагрузочного окна разумно сохранять все ошибки и traces выше порога длительности, плюс небольшую вероятностную выборку нормальных запросов. Если цель теста требует точного распределения каждой серверной фазы, sampling не заменяет метрики-гистограммы.
Метрики дают устойчивые квантили по всем запросам, traces объясняют отдельные хвосты. Инструментируйте одну и ту же фазу обоими сигналами: histogram llm.queue.duration строит p95, а span inference.acquire_slot раскрывает конкретный медленный запрос. OpenTelemetry рекомендует сопровождать значимую длительную операцию метрикой той же операции; это тот случай, где рекомендация действительно сокращает время расследования.
Следите за смещением выборки при join. Если backend сохраняет только медленные traces, paired network envelope будет представлять хвост, а не весь поток. Либо экспортируйте компактную серверную метрику с exemplar trace ID, либо на время контролируемого прогона включите согласованную выборку и запишите коэффициент. Отчет без описания sampling нельзя воспроизвести.
Содержимое prompt и ответа для измерения фаз не требуется. В регулируемой среде их запись создает риск утечки и увеличивает объем телеметрии. Достаточно диапазонов длины, модели, статуса, класса ошибки и технических идентификаторов с ограниченным сроком хранения.
Один отчет должен позволять принять решение
Хороший отчет хранит конфигурацию теста рядом с результатом: commit сценария k6, модель и endpoint, профиль токенов, arrival rate, регион генератора, streaming mode, правила sampling и версии схемы spans. Без этого сравнение двух p95 превращается в сравнение разных экспериментов.
На одном экране нужны клиентские TTFT и total, paired network envelope, gateway, GPU queue, generation, error rate, dropped iterations и join coverage. Покажите p50, p95 и p99, но принимайте решение по заранее выбранному SLO. Если после прогона команда двигает порог выше фактической p95, это не проверка, а оформление результата.
Храните также сырые пары длительностей для ограниченного числа прогонов. Агрегаты удобны для ворот, но они не позволят пересчитать новый бюджет или проверить ошибку join задним числом. Достаточно trace ID, статуса, класса запроса и чисел по фазам; тела запросов для этого не нужны. Сравнивайте релизы на одном профиле нагрузки и помечайте любое изменение маршрутизации, лимитов или аппаратного пула. Иначе разница между сборками будет отражать смену условий, а не код.
Для маршрутизатора моделей добавьте разрез по фактическому провайдеру и модели, но не смешивайте ветки в общий хвост. AI Router дает единый OpenAI-совместимый endpoint и маршрутизацию к разным моделям, поэтому trace должен сохранить выбранный маршрут как низкокардинальный атрибут без prompt и персональных данных. Такой разрез показывает, где задержку внес локальный шлюз, а где внешний inference-вызов, не привязывая методику к одному поставщику.
Первый критерий готовности прост: для случайно выбранного медленного request инженер за несколько минут находит trace и объясняет почти всю длительность суммой фаз. Второй критерий строже: автоматические пороги падают на той фазе, которая нарушила свой бюджет, а не только на общей p95. Пока эти два условия не выполнены, график задержки сообщает о симптоме и ничего не говорит о владельце исправления.
Часто задаваемые вопросы
Можно ли отделить сеть от GPU только метриками k6?
Нет. k6 видит клиентские фазы HTTP, но не знает, когда запрос ждал слот GPU и когда модель начала inference. Нужен серверный root span и дочерние spans, связанные с запросом по trace ID.
Почему нельзя вычесть серверную p95 из клиентской p95?
В хвост двух распределений обычно попадают разные запросы. Сначала сопоставьте клиентскую и серверную длительность каждого trace, вычислите разность, а затем считайте p95 полученного ряда.
Что именно показывает http_req_waiting в k6?
Метрика показывает время от окончания отправки запроса до первого байта ответа с точки зрения клиента. Она включает сеть до сервера, работу шлюза, очередь и начало генерации, поэтому не равна времени GPU.
Как измерять TTFT для потокового LLM-ответа?
Клиент должен поставить монотонную отметку при получении первого SSE-события с токеном. Сравнивайте ее с серверной отметкой записи первого токена, а полную длительность считайте отдельно до закрытия потока.
Какие spans нужны для очереди GPU?
Создайте отдельный span от момента передачи готового запроса планировщику до выдачи вычислительного слота. Не включайте туда валидацию, ожидание семафора шлюза или саму генерацию.
Какой порог p95 очереди GPU считать нормальным?
Универсального числа нет. Выделите бюджет из пользовательского SLO и зафиксируйте модель, приоритет, профиль токенов, arrival rate и окно измерения; иначе порог нельзя воспроизвести.
Нужна ли синхронизация часов между k6 и сервером?
Для сравнения длительностей точная синхронизация не нужна, потому что каждый процесс использует свои монотонные часы. Она нужна для удобной временной навигации по журналам, но вычитать абсолютные timestamps не следует.
Почему constant-arrival-rate лучше фиксированного числа VU?
Он удерживает заданную скорость прихода запросов при росте времени ответа и показывает накопление очереди. Фиксированное число VU снижает фактический RPS, когда запросы замедляются, и может скрыть насыщение.
Можно ли записывать все traces во время нагрузочного теста?
Можно только после проверки пропускной способности telemetry-контура. Следите за dropped spans и очередью экспортера; для больших прогонов сочетайте гистограммы со стратегией sampling, которая сохраняет ошибки и медленные traces.
Что делать, если провайдер не показывает внутреннюю GPU queue?
Не приписывайте неизвестную часть очереди. Измеряйте локальный gateway, общую длительность внешнего вызова и клиентский сетевой конверт, а внутреннюю очередь помечайте как недоступную для наблюдения.