Всем привет, с вами Низамов Илья. Модель unsloth/Qwen3.8-27B-NVFP4 я гоняю локально в vLLM на двух RTX 5070 Ti по 16 GB, и вопрос был простой: сколько контекста удастся получить и чем за это платить. Замеры я снимал не руками, а собственным инструментом с единой базой прогонов: пять конфигураций запуска, шесть прогонов, одиннадцать тестов на каждый, одна методика, один файл с результатами.
Всё, что ниже, пересобрано на замерах от 18 августа 2026 года по четвёртой версии методики — предыдущая редакция статьи считала часть метрик иначе, и цифры в ней устарели целиком.
Дальше: какое железо стоит под этим, что мерилось, что получилось, где на этом стенде настоящее узкое место и какую конфигурацию я бы взял под какую задачу.
Железо стенда
Начну с железа, и подробно. Без него любая цифра ниже читается неправильно: «56 токенов в секунду» ничего не значит, пока неизвестно, во что упирается конкретно эта машина. Всё, что в таблицах, — не по памяти, а из паспорта прогона: инструмент снимает конфигурацию сам, через lscpu, dmidecode, SPD модулей, /sys/bus/pci и nvidia-smi.
Процессор, плата, память
| Процессор | AMD Ryzen 5 3600, 6 ядер / 12 потоков, до 4208 MHz, один сокет, один NUMA-узел |
| Материнская плата | ASUS ROG STRIX X570-F GAMING, rev X.0x |
| BIOS | American Megatrends 5044 от 04.01.2026 |
| Оперативная память | 78032 MB, четыре слота из четырёх заняты |
| Модули | 2 × G.Skill F4-3600C18-32GVK по 32 GB (двухранговые) + 2 × Patriot «4000 C19 Series» по 8 GB (одноранговые) |
| Режим | DDR4, 3266 MT/s на всех четырёх модулях |
| Тайминги | не прочитаны, и это честнее выдумки: 3266 MT/s нет ни в одном профиле SPD (у G.Skill — JEDEC 2666 и XMP 3600, у Patriot — JEDEC 2133, XMP 4000 и 3866), то есть частота и задержки набраны в BIOS вручную |
| Пропускная способность ОЗУ (замер) | чтение 42.0 GB/s, копирование 39.1 GB/s, запись 26.0 GB/s |
| Операционная система | Ubuntu 26.04 LTS, ядро Linux 7.0.0-29-generic |
| Драйвер и CUDA | NVIDIA 595.71.05, CUDA 13.2 |
Две детали, которые видно только в паспорте.
Память собрана из двух разных комплектов. 64 GB G.Skill плюс 16 GB Patriot — и именно поэтому ни один XMP-профиль не включён: на смешанном наборе плата не вытянула ни 3600, ни 3866, частоту пришлось задавать руками. 3266 MT/s — это компромисс, а не паспорт. Инструмент такой случай не маскирует: он пишет «таких профилей в SPD нет» вместо того, чтобы подставить красивые «3600 CL18».
Контроллер памяти выбирается вторым потоком. Развёртка по числу потоков дала на копировании 35.6 → 39.1 → 37.2 → 35.7 → 34.6 GB/s на 1, 2, 4, 8 и 12 потоках. То есть шести ядрам 2019 года хватает двух, чтобы упереться в память, а дальше потоки только мешают друг другу. Для наших цифр это почти неважно — веса целиком лежат в VRAM, в системную память ничего не выгружается, — но процессор здесь самое старое звено, и он обслуживает планировщик vLLM и семплер llama.cpp.
Видеокарты и шина PCIe
| Карта | VRAM | Лимит мощности | VBIOS | Слот | Линк под нагрузкой |
|---|---|---|---|---|---|
| RTX 3090 (MSI) | 24576 MB | 370 W | 94.02.42.00.F1 | PCIe 4.0 x4 | Gen4 x4 — упирается в плату |
| RTX 5070 Ti (ASUS) | 16303 MB | 300 W | 98.03.58.40.B6 | PCIe 4.0 x8 | Gen4 x8 — карта умеет Gen5, хост нет |
| RTX 5070 Ti (ASUS) | 16303 MB | 300 W | 98.03.58.00.78 | PCIe 4.0 x8 | Gen4 x8 — то же самое |
NVLink нет ни у одной пары. И вот это — главный факт о стенде, к которому я вернусь в разделе про третью карту:
- 3090 сидит в слоте, разведённом на четыре линии. Не карта такая, а плата:
slot_max_widthу этого слота равен 4, и райзером это не лечится. Инструмент помечает такую карту «упирается в плату». - 5070 Ti работают на Gen4, хотя обе умеют Gen5 — потолок ставит платформа X570.
- Линк снимается под нагрузкой, а не на простое. В простое карта уходит в Gen1 ради энергосбережения, и снимок «до прогона» показал бы «PCIe 1.0 x8» на исправной машине. Фоновый наблюдатель опрашивает драйвер раз в две секунды и держит максимум.
- У двух 5070 Ti разные версии VBIOS. На результаты это не влияло, но карты не идентичны, и в паспорте это видно.
Между сериями замеров машина пересобиралась: в прогонах vLLM инструмент видел две 5070 Ti на адресах 0000:08:00.0 и 0000:09:00.0, в прогонах llama.cpp — три карты на 04, 09 и 0a, с 3090 первой по шине. Отсюда CUDA_VISIBLE_DEVICES=0,1 в одних командах и 0,1,2 в других.
Сколько остаётся на KV-кэш
Чекпойнт NVFP4 занимает 21.81 GiB. При тензорном параллелизме на две карты на каждую приходится около 11 GiB весов, а полезного объёма у карты 15.47 GiB. Остаётся примерно 3 GiB на всё остальное: KV-кэш, буферы активаций, графы CUDA.
Отсюда вся возня с флагами ниже. Контекст на этом стенде измеряется не желаниями, а этими тремя гигабайтами: сто тысяч токенов честного восьмибитного кэша в них не помещаются, и каждая конфигурация — это свой способ договориться с ограничением.
Что показывают датчики под нагрузкой
| Прогон | Карты | Потребление в конце прогона | Температура | Загрузка GPU |
|---|---|---|---|---|
| vLLM, две карты | 5070 Ti + 5070 Ti | 145–167 W из 300 | 55–58 °C | 82–100 % |
| llama.cpp, три карты | 3090 + 2 × 5070 Ti | 206 / 82 / 124 W | 57–63 °C | 21 / 14 / 17 % |
Два числа отсюда пригодятся дальше. Первое: под vLLM карты берут около половины разрешённой мощности. Батч единица — это не про вычисления, это про чтение весов из памяти, и кремний скучает. Второе: под llama.cpp на трёх картах загрузка 14–21 %, то есть карты большую часть времени ждут друг друга.
Как снимались замеры
Все шесть прогонов сняты одним инструментом, батчем 1, с фиксированным зерном и анти-кэш-маркером в начале каждого промпта. Определения важны: половина расхождений между обзорами в интернете — это разные формулы под одним словом.
Прежде чем показывать цифры — что каждая из них означает человеческими словами. Дальше по тексту в таблицах стоят именно эти названия, технический термин остаётся в скобках.
- Чтение запроса (префилл). Прежде чем написать первое слово, модель молча читает весь запрос: вопрос, приложенные файлы, историю переписки. Всё это время экран пустой. Метрика — сколько текста она успевает прочитать за секунду.
- Пауза до первого слова (TTFT). Столько проходит между нажатием Enter и первой буквой ответа. На коротком вопросе это незаметно, на большом документе из этого состоит всё ожидание.
- Скорость ответа (генерация) и чистая скорость печати (декодирование). Первая считает всё время запроса вместе с паузой — это то, что чувствует человек. Вторая — только сам набор текста, без ожидания.
- Подтверждённый контекст. Движок соглашается принять сколько-то токенов — это цифра из настроек запуска. Проверяем делом: прячем в длинный текст фразу, которой модель знать не может, и просим найти. Прячем трижды — в начале, в середине и в конце. Подтверждённый контекст — самая большая длина, где нашлись все три.
- Экономия на повторах (кэш префикса). Начало диалога модель уже читала, и второй раз может его не перечитывать. Метрика — во сколько раз повторный запрос быстрее холодного и с какой длины эта экономия вообще включается.
- Исправность сборки. Не «умная ли модель», а не поглупела ли она от сжатия и не сломана ли обвязка: отвечает ли строго по заданной форме, решает ли задачи с заранее известным ответом, не мешает ли языки в одном ответе, приходит ли на один и тот же вопрос дважды один и тот же текст.
- Агентская траектория. Агент не задаёт один вопрос: он читает историю и на каждом шаге выбирает инструмент. Прогоняем 26 записанных заранее ходов и смотрим, сколько раз инструмент выбран верно и сколько длится обычный шаг.
А теперь формально — как каждая метрика считается:
| Что меряем | Как считается |
|---|---|
| Скорость ответа (генерация), ток/с | выходные токены / полное время запроса, включая ожидание первого токена — то, что чувствует пользователь |
| Чистая скорость печати (декодирование), ток/с | выходные токены / (полное время − TTFT) — чистая скорость выдачи |
| Пауза до первого слова (TTFT) | время до первого токена потока; отдельно на короткой реплике и на деловом промпте ~450–500 токенов |
| Чтение запроса (префилл), ток/с | наклон прямой t(N) = a + N/s по лестнице промптов 512 / 2048 / 8192 / 16384 токена, метод наименьших квадратов, по три повтора на точку, в зачёт минимум |
| Постоянные потери на запрос (накладные), с | свободный член a той же прямой — «сколько стоит дотянуться до движка» |
| Подтверждённый контекст | наибольшая длина, на которой модель нашла «иголку» сразу на трёх глубинах (10 %, 50 %, 90 %) |
| Падение скорости по длине | во сколько раз падает скорость на каждое удвоение длины промпта |
| Экономия на повторах (кэш префикса) | TTFT холодного запроса / TTFT повторного, плюс подтверждение по cached_tokens и счётчику vllm:prefix_cache_hits_total |
| Исправность сборки | четыре блока с равным весом: structured output и шаблон чата, 25 задач с проверяемым ответом, языковая целостность, побайтовая повторяемость |
| Агентская траектория | 26 фиксированных ходов, 25 схем инструментов в каждом запросе, история растёт до 28720 токенов |
Три оговорки, без которых цифры ниже читаются неправильно.
Про префилл. Самый очевидный способ — prompt_tokens / TTFT — занижает результат в разы. В TTFT кроме собственно обработки входа сидят HTTP-запрос, постановка в слот, токенизация, генерация первого токена и флаш чанка; на llama.cpp эта постоянная составляющая занимала до 86 % измеренного времени. Поэтому считается не отношение, а скорость роста: строится прямая по четырём длинам промпта, наклон даёт префилл, свободный член — накладные расходы. Не легли на прямую (R² ниже 0.95) — число не публикуется вовсе. У всех шести прогонов R² вышел не ниже 0.9993, так что здесь публикуется всё.
Про падение скорости по длине. Раньше это было отношение первой точки лестницы к последней — и такое число нельзя сравнивать между конфигурациями с разным потолком контекста: у одной лестница проходит пять удвоений, у другой два, и «1.05 против 1.84» читается как «стало лучше», а означает «мерили короче». Теперь падение приведено к одному удвоению длины. Это единственная форма, в которой конфигурации с потолком 65536 и 262144 сравнимы напрямую.
Про подтверждённый контекст. Это верхняя точка лестницы, на которой иголка нашлась на всех трёх глубинах, а верхнюю точку ставит потолок движка. Сравнивать «248380 у первой конфигурации против 61533 у второй» бессмысленно — сравнивать надо долю подтверждённого от заявленного и общее начало лестницы, которое у всех прогонов побайтово одинаковое.
Шесть прогонов одной таблицей
Чтобы дальше было понятно, о чём речь, — весь набор сразу. Разбор каждой конфигурации ниже.
| К1 | К2 | К3 | К4 | К5a, по слоям | К5b, по тензорам | |
|---|---|---|---|---|---|---|
| Движок | vLLM 0.27.1 | vLLM 0.27.1 | vLLM 0.27.1 | vLLM 0.27.1 | llama.cpp b10446 | llama.cpp b10446 |
| Карты | 2 × 5070 Ti | 2 × 5070 Ti | 2 × 5070 Ti | 2 × 5070 Ti | 3090 + 2 × 5070 Ti | 3090 + 2 × 5070 Ti |
| KV-кэш | turboquant 4 бита | fp8_e4m3 | fp8_e4m3 + mamba bf16 | fp8_e4m3 | q8_0 | q8_0 |
| Угадывание наперёд (MTP) | нет | есть, ×4 | есть, ×4 | нет | есть, ×4 (draft-mtp) | есть, ×4 (draft-mtp) |
| Vision | выключен | на месте | выключен | выключен | — | — |
| Заявленный контекст | 262144 | 65536 | 131072 | 170912 | 262144 | 262144 |
| Подтверждённый | 248380 | 61533 | 123848 | 123848 | 123848 | 123848 |
| Скорость ответа, ток/с | 54.79 | 98.59 | 100.73 | 55.43 | 46.85 | 62.33 |
| Чистая скорость печати, ток/с | 55.67 | 101.24 | 102.57 | 56.30 | 49.74 | 68.49 |
| Пауза до первого слова, с | 0.091 | 0.060 | 0.057 | 0.096 | 0.476 | 0.549 |
| Чтение запроса, ток/с | 3506 | 3362 | 3140 | 3360 | 2118 | 1177 |
| Постоянные потери на запрос, с | −0.006 | +0.004 | −0.009 | −0.016 | +0.417 | +0.385 |
| Чтение медленнее, на удвоение длины | ×1.19 | ×1.13 | ×1.17 | ×1.16 | ×1.19 | ×1.08 |
| Печать медленнее, на удвоение длины | ×1.20 | ×1.01 | ×1.02 | ×1.02 | ×1.06 | ×1.07 |
| Экономия на повторах включилась | с 5256 ток. | с 5256 ток. | с 2856 ток. | с 1656 ток. | с 656 ток. | с 656 ток. |
| Исправность сборки | 100 % | 100 % | 100 % | 100 % | 100 % | 100 % |
| Верный выбор инструмента | 88.5 % | 88.5 % | 96.2 % | 84.6 % | 92.3 % | 88.5 % |
| Обычный шаг агента, с | 2.22 | 1.77 | 1.52 | 2.02 | 2.83 | 2.94 |
Одна строка требует пояснения сразу: исправность сборки 100 % у всех шести. В прошлой редакции статьи у vLLM было 90–95 %, и весь провал держался на одном сценарии — системном сообщении не первым в списке. Теперь все четыре конфигурации vLLM запускаются с --chat-template /home/ilya/ai/qwen38-nvfp4-fixed.jinja, и провал исчез. Это ровно та правка, до которой в прошлый раз я дошёл рассуждением, а теперь она подтверждена замером.
Конфигурация 1: предел — 262144 токена ценой скорости
Полный нативный контекст модели на двух картах по 16 GB. Достаётся экстремальным квантованием KV-кэша, и, забегая вперёд, в постоянной работе я её не держу — но как демонстрация предела она показательна.
source unsloth-nvfp4-env/bin/activate
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=0,1 \
vllm serve unsloth/Qwen3.8-27B-NVFP4 --host 0.0.0.0 --port 8000 \
--tensor-parallel-size 2 --gpu-memory-utilization 0.965 \
--kv-cache-dtype turboquant_4bit_nc --language-model-only \
--kv-cache-memory-bytes 2400000000 \
--max-num-seqs 8 --max-num-batched-tokens 1024 \
--reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder \
--enable-prefix-caching --max-model-len 262144 --enable-prompt-tokens-details \
--chat-template /home/ilya/ai/qwen38-nvfp4-fixed.jinja
| Что меряем | Значение |
|---|---|
| Заявленный контекст | 262144 |
| Подтверждённый контекст | 248380 — иголка найдена на всех трёх глубинах |
| Скорость ответа / чистая печать | 54.79 / 55.67 ток/с |
| Свободный ответ / ответ строго по форме | 54.17 / 51.27 ток/с |
| Пауза до первого слова: короткая реплика / деловой промпт | 0.091 / 0.148 с |
| Чтение запроса | 3506 ток/с (накладные −0.006 с, R² 0.9997) |
| Экономия на повторах | включилась только с 5256 токенов, медианный выигрыш ×0.98 |
| Чтение / печать медленнее на удвоение длины | ×1.19 / ×1.20 |
| Исправность сборки | 100 % |
| Агент: верный инструмент / экономия на повторах / рост паузы / обычный шаг | 88.5 % / 83.2 % / ×0.54 / 2.22 с |
Главная новость этого прогона: 248380 токенов подтверждены замером. Иголка — факт, которого нет в весах, — вставлена на глубины 10 %, 50 % и 90 % от четверти миллиона токенов, и модель нашла её во всех трёх случаях. То есть 262144 в конфиге — не декорация, контекст действительно живой по всей длине.
Плата за это видна ровно там же. На 248380 токенах время до первого токена — 149.9 с, а скорость выдачи падает до 13.05 ток/с против 55.4 на коротком промпте. Полный холодный запрос с ответом в 300 токенов на такой длине идёт почти три минуты, и 87 % этого времени — чтение входа.
Разбор параметров
--gpu-memory-utilization 0.965. Стандартные 0.95 оставляют на столе примерно четверть гигабайта. Выше 0.9689 подняться физически нельзя: свободно на карте 14.99 из 15.47 GiB. Значение 0.98 не стартует вообще, а подсказки самой vLLM в логе («увеличьте до 0.9841», в другом прогоне — до 0.9991) невыполнимы: они считают от полного объёма карты, а не от свободного.
--language-model-only. Модель мультимодальная: помимо 64 текстовых слоёв в ней 27 блоков vision-энкодера. Флаг убирает их: веса на карту падают с 10.67 до 10.24 GiB, доступный KV растёт с 2.64 до 3.31 GiB, потолок контекста — на 26 %. Прибавка к KV больше экономии на весах, потому что vision перестаёт участвовать ещё и в профилировании пиковых активаций.
--kv-cache-dtype turboquant_4bit_nc. Главный рычаг и главный компромисс. TurboQuant — это post-training квантование KV-кэша от Google Research: поворот вектора преобразованием Адамара, дальше квантование ключей по Ллойду-Максу и значений равномерно. Дообучения не требует, в vLLM 0.27.1 это готовый флаг с четырьмя пресетами.
Что стоит знать до включения:
- Заявленные «в шесть раз меньше памяти» считаются от FP16, а у нас KV уже в FP8. Честная дельта к текущему состоянию:
k8v4даёт ×1.32,4bit_nc— ×1.95,k3v4_nc— ×2.23,3bit_nc— ×2.59. - Качество платное, и цифры лежат в самом коде vLLM: рост перплексии +1.17 % у
k8v4, +2.71 % у4bit_nc, +10.63 % уk3v4_ncи +20.59 % у3bit_nc. Последний пресет на гибридных моделях лучше не трогать вовсе — там нет защиты граничных слоёв. - Сжимаются только 16 слоёв из 64: состояния mamba-слоёв TurboQuant не трогает. Поэтому отдача растёт вместе с длиной контекста — на 8k экономия около 31 % памяти на запрос, на 32k уже 43 %.
- Реализация в vLLM неполная: вторая ступень (QJL, однобитный остаток) выброшена намеренно — в комментарии к коду сказано, что несколько независимых групп нашли ухудшение качества внимания.
--kv-cache-memory-bytes 2400000000. Самый неочевидный флаг: он не добавляет контекста, он возвращает память рантайму. Если отдать TurboQuant всё, что есть, сервер стартует, отвечает на короткие запросы и выглядит здоровым — а потом приходит длинный промпт, и движок умирает:
File ".../vllm/v1/attention/backends/turboquant_attn.py", line 857, in _continuation_prefill
torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 204.00 MiB.
GPU 0 has a total capacity of 15.47 GiB of which 202.06 MiB is free.
...
vllm.v1.engine.exceptions.EngineDeadError
Двухсот четырёх мегабайт не хватило при двухстах двух свободных. При поэтапном префилле TurboQuant распаковывает уже сохранённый префикс, и временный буфер под это растёт вместе с длиной префикса. Профилировщик памяти его не видит: профилирование идёт с крошечным KV-кэшем, где префикса ещё нет. 2.4 GB — чуть больше, чем нужно на 262144 токена, остальное сознательно оставлено движку.
Чем эта конфигурация расплачивается
Число 262144 в конфиге настоящее, но полезным его назвать трудно, и ломаются три вещи.
Печатает всё медленнее по мере роста промпта — сильнее всех в наборе. На коротком вопросе это 55 токенов в секунду, к 124 тысячам остаётся 21, к 248 тысячам — 13. Каждое удвоение длины отнимает пятую часть скорости (×1.20 — худший результат набора). Честный fp8 на тех же удвоениях не теряет почти ничего (×1.02): у него на 124 тысячах всё ещё 51 токен в секунду. На графике это видно нагляднее, чем в цифрах: на коротком вопросе две конфигурации неразличимы, расходятся они только на длине.
Экономии на повторах почти нет. Обычно модель не перечитывает начало диалога, которое уже видела, и повторный запрос приходит быстрее. Здесь это включается только с 5256 токенов: на всём, что короче, второй запрос идёт ровно столько же, сколько первый. Причина в устройстве кэша: при четырёхбитном хранении он кладётся кусками по 3072 токена, и короткий префикс в такой кусок целиком не набирается. Плюс пул KV урезан вручную до 2.4 GB. Отсюда и «×0.98» в сводной таблице — это медиана по четырём длинам, из которых сработала одна.
В агентской сессии это стоит времени. Переиспользование кэша 83.2 % — не худшее в наборе, но медиана хода 2.22 с при том, что декодирование у К1 такое же, как у К4, а у К4 медиана 2.02 с.
Как вообще добраться до такого контекста
Отправная точка — 70997 токенов в пуле KV на штатных настройках. Путь до полных 262144 занял четыре флага, и один из них не добавил ни токена: он в лестнице вообще не за этим. Ниже — та же лестница по шагам:
Отдельно про методику поиска потолка. Число GPU KV cache size в логе — не потолок контекста, оно показывает расклад для текущей длины и ошибается в полтора раза. Правильный способ: задать заведомо недостижимый --max-model-len и прочитать из ValueError строку estimated maximum model length — один запуск вместо бинарного поиска. Именно так получены некруглые 170912 в конфигурации 4.
Конфигурация 2: 65536 токенов, зато модель целиком (vision на месте)
Честный восьмибитный кэш, спекулятивное декодирование, увеличенный батч предзаполнения и единственная конфигурация, где модель поднимается целиком, вместе с vision-частью. Платим контекстом: 65536 токенов.
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=0,1 PYTHONUNBUFFERED=1 \
vllm serve unsloth/Qwen3.8-27B-NVFP4 --host 0.0.0.0 --port 8000 \
--tensor-parallel-size 2 --max-num-seqs 8 --max-num-batched-tokens 4096 \
--kv-cache-dtype fp8_e4m3 --gpu-memory-utilization 0.965 \
--reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder \
--enable-prefix-caching \
--speculative-config '{"method": "mtp", "num_speculative_tokens": 4}' \
--enable-prompt-tokens-details --max-model-len 65536 \
--chat-template /home/ilya/ai/qwen38-nvfp4-fixed.jinja
| Что меряем | Значение |
|---|---|
| Заявленный контекст | 65536 |
| Подтверждённый контекст | 61533 — весь заявленный, иголка найдена на трёх глубинах |
| Скорость ответа / чистая печать | 98.59 / 101.24 ток/с |
| Свободный ответ / ответ строго по форме | 119.16 / 96.01 ток/с |
| Пауза до первого слова: короткая реплика / деловой промпт | 0.060 / 0.171 с |
| Чтение запроса | 3362 ток/с (накладные +0.004 с) |
| Экономия на повторах | с 5256 токенов, медиана ×0.97 |
| Чтение / печать медленнее на удвоение длины | ×1.13 / ×1.01 |
| Исправность сборки | 100 % |
| Агент: верный инструмент / экономия на повторах / рост паузы / обычный шаг | 88.5 % / 74.0 % / ×1.04 / 1.77 с |
Здесь важно, что лестница длин дошла до конца заявленного потолка: 61533 токена — это верхняя ступень, которую заказывали как 65536, и стог по построению выходит на 6 % короче цели. В прошлой редакции статьи эта конфигурация обрывалась на 15370 токенах, и я честно писал «сравнивать нельзя». Обрывалась она из-за отсечки «не выше 90 % потолка» в старой методике: потолок сам оказался точкой сетки, и точка вылетала целиком. Теперь отсечка считается по замеренному расширению стога, и верхние две трети контекста наконец измерены.
Разбор параметров
--max-num-batched-tokens 4096. Этому флагу принято приписывать кратный рост префилла. На замере это не подтверждается: 3362 ток/с против 3360 у конфигурации 4 с батчем 1024 — разница в шестую долю процента. Впечатление кратного роста берётся из формулы prompt_tokens / TTFT: большой батч меняет в основном постоянную составляющую, а она в этой формуле и сидит.
Vision остаётся. Здесь нет --language-model-only, и это стоит примерно 26 % контекста — но модель поднимается со всеми возможностями. Если картинки нужны, платить приходится именно контекстом, других вариантов на 32 GB VRAM нет.
Цена за отказ от --language-model-only видна в кэше. Переиспользование кэша в агентской сессии 74.0 % — худшее из шести прогонов, и это единственный прогон, где TTFT по сессии вырос (×1.04), то есть каждый следующий ход давался движку не легче, а тяжелее. Кэш префикса включается только с 5256 токенов, как у К1, хотя KV здесь честный восьмибитный.
Конфигурация 3: модель угадывает наперёд (MTP), +82 % к скорости
Та же спекуляция, но vision убран, а состояние mamba-слоёв переведено в bf16. Контекст — 131072. Это лучший прогон набора почти по всем метрикам, которые чувствуются руками.
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=0,1 \
vllm serve unsloth/Qwen3.8-27B-NVFP4 --host 0.0.0.0 --port 8000 \
--tensor-parallel-size 2 --gpu-memory-utilization 0.965 \
--language-model-only --max-num-seqs 8 --max-num-batched-tokens 1024 \
--kv-cache-dtype fp8_e4m3 --mamba-ssm-cache-dtype bfloat16 \
--speculative-config '{"method": "mtp", "num_speculative_tokens": 4}' \
--reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder \
--enable-prefix-caching --enable-prompt-tokens-details --max-model-len 131072 \
--chat-template /home/ilya/ai/qwen38-nvfp4-fixed.jinja
| Что меряем | Значение |
|---|---|
| Заявленный контекст | 131072 |
| Подтверждённый контекст | 123848 — весь заявленный |
| Скорость ответа / чистая печать | 100.73 / 102.57 ток/с |
| Свободный ответ / ответ строго по форме | 122.09 / 99.67 ток/с |
| Пауза до первого слова: короткая реплика / деловой промпт | 0.057 / 0.171 с |
| Чтение запроса | 3140 ток/с (накладные −0.009 с) |
| Экономия на повторах | с 2856 токенов, до ×4.66 на 5256 токенах |
| Чтение / печать медленнее на удвоение длины | ×1.17 / ×1.02 |
| Исправность сборки | 100 % |
| Агент: верный инструмент / экономия на повторах / рост паузы / обычный шаг | 96.2 % / 82.4 % / ×0.93 / 1.52 с |
Разбор параметров
--speculative-config '{"method": "mtp", "num_speculative_tokens": 4}'. Простыми словами: рядом с большой моделью работает маленькая быстрая голова, которая пишет несколько слов наперёд, а большая модель проверяет их все разом, за один проход. Угадала — эти слова уже готовы, не угадала — лишний черновик выбрасывается. В чекпойнте эта голова лежит готовой (model_mtp.safetensors, 811 MB), отдельно её качать не надо.
Замер против ближайшего соседа — конфигурации 4, отличающейся отсутствием MTP и --mamba-ssm-cache-dtype:
| Что меряем | К3, угадывает наперёд | К4, не угадывает | разница |
|---|---|---|---|
| Скорость ответа | 100.73 ток/с | 55.43 ток/с | +82 % |
| Чистая скорость печати | 102.57 ток/с | 56.30 ток/с | +82 % |
| Свободный ответ | 122.09 ток/с | 55.00 ток/с | +122 % |
| Пауза до первого слова | 0.057 с | 0.096 с | −41 % |
| Обычный шаг агента | 1.52 с | 2.02 с | −25 % |
| Чтение запроса | 3140 ток/с | 3360 ток/с | −6.5 % |
| Заявленный контекст | 131072 | 170912 | −23 % |
Восемьдесят процентов к скорости выдачи за 6.5 % скорости чтения — размен, который на этом железе выглядит однозначным. Дальше по тексту будет граница, где он перестаёт работать, но лежит она сильно правее, чем кажется.
Сколько черновых токенов брать — вопрос не такой простой, как кажется. Слой MTP в модели один, и при num_speculative_tokens > 1 он прогоняется несколько раз подряд, о чём vLLM честно предупреждает в логе. Второй черновой токен проходит куда реже первого — но итоговая скорость от этого не падает: за один шаг движка выдаётся больше токенов. С четырьмя черновыми токенами против двух в прошлой редакции замеров генерация выросла с 92 до 101 ток/с.
Плата — память: MTP добавляет 0.40 GiB весов (черновой слой лежит в bf16, он в списке исключений квантователя) и забирает 0.47 GiB доступного KV, плюс выравнивание слоёв — в логе это Add 3 padding layers, may waste at most 6.25% KV cache memory.
--mamba-ssm-cache-dtype bfloat16. Модель гибридная: из 64 слоёв только 16 — полноценное внимание, остальные 48 — linear attention (GDN/mamba) с состоянием фиксированного размера вместо растущего KV. Флаг переводит это состояние в bf16. Со спекулятивным декодированием он даёт около +3 % контекста — втрое больше, чем без него: с MTP состояние свёрточной части крупнее, и его сжатие приносит больше. Заодно вдвое падает размер блока внимания — и это видно в кэше префикса: у К3 он включается с 2856 токенов, а не с 5256, как у К1 и К2.
131072 — это круглое число, а не потолок. В отличие от 170912 у конфигурации 4, здесь я не искал предел, а взял степень двойки: со спекуляцией потолок ниже, чем без неё, и упираться в него на каждый запуск незачем.
Конфигурация 4: максимум без сжатия кэша — 170912 токенов
Убираем спекуляцию и смотрим, до какой длины дотягивается честный fp8-кэш.
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=0,1 \
vllm serve unsloth/Qwen3.8-27B-NVFP4 --host 0.0.0.0 --port 8000 \
--tensor-parallel-size 2 --gpu-memory-utilization 0.965 \
--language-model-only --max-num-seqs 8 --max-num-batched-tokens 1024 \
--kv-cache-dtype fp8_e4m3 \
--reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder \
--enable-prefix-caching --enable-prompt-tokens-details --max-model-len 170912 \
--chat-template /home/ilya/ai/qwen38-nvfp4-fixed.jinja
| Что меряем | Значение |
|---|---|
| Заявленный контекст | 170912 |
| Подтверждённый контекст | 123848 — верхняя точка лестницы, а не предел конфигурации |
| Скорость ответа / чистая печать | 55.43 / 56.30 ток/с |
| Свободный ответ / ответ строго по форме | 55.00 / 51.11 ток/с |
| Пауза до первого слова: короткая реплика / деловой промпт | 0.096 / 0.134 с |
| Чтение запроса | 3360 ток/с (накладные −0.016 с) |
| Экономия на повторах | с 1656 токенов, до ×7.86 на 5256 токенах |
| Чтение / печать медленнее на удвоение длины | ×1.16 / ×1.02 |
| Исправность сборки | 100 % |
| Агент: верный инструмент / экономия на повторах / рост паузы / обычный шаг | 84.6 % / 88.1 % / ×0.76 / 2.02 с |
Про подтверждённый контекст здесь надо читать внимательно. Лестница длин идёт степенями двойки: 1024, 4096, 16384, 65536, 131072, 262144. Следующая после 131072 ступень — 262144 — в заявленные 170912 не влезает, поэтому лестница остановилась, и 123848 — это «до сих пор дошли и всё ещё находили иголку», а не «дальше слепнет». Доля подтверждённого от заявленного у К4 самая низкая в наборе (72 %) ровно по этой причине, а не из-за модели.
Зато у К4 лучший в семействе vLLM кэш префикса: он включается уже с 1656 токенов и даёт до ×7.86. Блок кэша при честном fp8 и без спекуляции вышел 1568 токенов — вчетверо меньше, чем при четырёхбитном хранении, — и короткие префиксы в него попадают.
170912 — не круглое число, потому что его назвала сама vLLM: заявляешь заведомо недостижимую длину, читаешь estimated maximum model length из отказа и ставишь ровно её. Округлять вверх нельзя, и вот почему.
Ловушка: запрос чуть короче лимита виснет навсегда
Запрос с промптом 166465 токенов при --max-model-len 167776 (то есть внутри лимита) завис. Не упал, не вернул ошибку — завис: двадцать минут, ни одной новой строки в логе, Running: 0 reqs, Waiting: 0 reqs, загрузка GPU ноль. Сервер при этом живой, короткие запросы обрабатывает.
Арифметика такая: пул 167776 токенов — это ровно 107 блоков по 1568. Блок номер ноль vLLM резервирует как null-block, значит реально распределяемых 106 блоков, то есть 166208 токенов. Запрос на 166865 требует 107 блоков и не может быть запланирован никогда, а планировщик не считает это ошибкой — он просто ждёт.
Лечение: выравнивать --max-model-len по границе блока, отняв один блок. Тогда негабаритный запрос получает честный HTTP 400 мгновенно.
Почему нельзя взять и контекст, и скорость сразу
Коротко: четырёхбитное сжатие кэша ради длины и угадывание наперёд ради скорости вместе не собираются — берём либо длину, либо скорость.
Напрашивается очевидное: включить TurboQuant ради контекста и MTP ради скорости. Связка стартует, отдаёт полные 262144 токена и показывает высокую долю принятых черновых токенов. И генерирует мусор.
Воспроизводится детерминированно: две независимые конфигурации, четыре промпта, температура ноль. Контроли на месте — FP8 со спекуляцией отвечает нормально, четыре бита без спекуляции отвечают нормально. На полном контексте эта же связка убивает движок: CUDA error: an illegal memory access в сэмплере отбраковки, оба воркера, HTTP 500.
Причина нашлась в коде: у бэкенда TurboQuant стоит supports_spec_as_decode=False, поэтому порог переупорядочивания батча остаётся равным единице. Шаг спекуляции приходит с несколькими токенами в запросе, не проходит порог «это декод» и уходит в ветку префилла — с синтетическими длинами последовательностей. Формально код отрабатывает, считает неверно. Первые 3–6 токенов всегда правильные: самый первый шаг идёт вообще без черновика.
И главная ловушка: доля принятых драфт-токенов эту поломку не детектирует. На сломанной связке она равна 93.6 % против 78.4 % у исправной — модель печатает повторяющийся мусор, а мусор черновая голова предсказывает идеально.
Корректность спекулятивного декодирования проверяется чтением текста, а не метрикой. Именно поэтому в наборе выше нет конфигурации «четыре бита плюс MTP»: она собирается, но её результаты нельзя публиковать.
Конфигурация 5: спасёт ли третья видеокарта?
vLLM не умеет делить эту модель на три карты: тензорный параллелизм требует, чтобы число KV-голов делилось на размер группы, а у неё их четыре — допустимы 2 и 4, но не 3. Плюс 3090 не умеет NVFP4. Значит, llama.cpp и GGUF-сборка unsloth/Qwen3.8-27B-GGUF в кванте UD-Q8_K_XL — флаг -hf скачает её с Hugging Face сам.
Здесь два прогона: они отличаются ровно одним флагом — способом деления модели между картами.
CUDA_VISIBLE_DEVICES=0,1,2 ./build/bin/llama-server \
-hf unsloth/Qwen3.8-27B-GGUF:UD-Q8_K_XL \
--host 0.0.0.0 --port 8000 \
--ctx-size 262144 --parallel 1 \
--cache-type-k q8_0 --cache-type-v q8_0 --flash-attn on \
--spec-draft-n-max 4 --spec-type draft-mtp \
--temp 0.7 --top-p 0.8 --top-k 20 --presence-penalty 1.5 --min-p 0.00 \
--reasoning off --split-mode layer \
--batch-size 2048 --ubatch-size 512 --jinja \
--chat-template-file /home/ilya/ai/qwen38-fixed.jinja
Второй прогон — та же команда с --split-mode tensor.
| Что меряем | По слоям (--split-mode layer) | Каждый слой на все карты (--split-mode tensor) |
|---|---|---|
| Скорость ответа | 46.85 ток/с | 62.33 ток/с |
| Чистая скорость печати | 49.74 ток/с | 68.49 ток/с |
| Свободный ответ / ответ по форме | 51.88 / 52.89 | 70.91 / 75.49 |
| Пауза до первого слова | 0.476 с | 0.549 с |
| Чтение запроса | 2118 ток/с | 1177 ток/с |
| Постоянные потери на запрос | 0.417 с | 0.385 с |
| Экономия на повторах | ×9.12 с 656 токенов | ×10.56 с 656 токенов |
| Чтение / печать медленнее на удвоение длины | ×1.19 / ×1.06 | ×1.08 / ×1.07 |
| Исправность сборки | 100 % | 100 % |
| Агент: верный инструмент / экономия на повторах / обычный шаг | 92.3 % / 93.9 % / 2.83 с | 88.5 % / 93.9 % / 2.94 с |
Сразу оговорка про квант. В имени файла стоит UD-Q8_K_XL, а сам движок в /props называет model_ftype как Q4_K - Medium. Это динамический квант unsloth со смешанной разрядностью слоёв, и заголовок GGUF отражает базовый тип, а не фактическую раскладку. Поэтому называть эту сборку «восьмибитной» я не берусь — по паспорту движка она четырёхбитная с восьмибитными вставками. На исправность сборки это не повлияло: 100 % по всем четырём блокам.
Что дают эти флаги
--split-mode layer против --split-mode tensor. Это два способа поделить модель между картами: либо каждой карте достаётся своя пачка слоёв, либо каждый слой считают все карты сразу. Здесь и есть ответ на вопрос про третью карту, и упирается он в те самые четыре линии PCIe.
- Тензорное деление заставляет все три карты работать над каждым токеном — суммарной пропускной способности памяти становится больше, и декодирование вырастает на 38 % (68.49 против 49.74 ток/с). Но за каждый слой платится обменом между картами, а один из участников сидит на Gen4 x4. На префилле, где через шину едут не отдельные токены, а целые батчи активаций, это обрушивает скорость на 44 %: 1177 против 2118 ток/с.
- Послойное деление гоняет по шине только активации на границах слоёв — префилл остаётся вдвое выше, но карты работают по очереди, и декодирование проседает.
Ни один из режимов не обгоняет vLLM на двух картах: 2118 и 1177 ток/с префилла против 3360 у конфигурации 4. Разница в 1.6–2.9 раза.
--cache-type-k q8_0 --cache-type-v q8_0. Восьмибитный KV-кэш — аналог fp8 у vLLM, но квантование здесь блочное. На 262144 токена без него памяти не хватило бы даже втроём. Под нагрузкой заняли 19.0 / 14.7 / 15.4 GB — почти 50 GB VRAM на модель с полным контекстом.
--chat-template-file. Свой шаблон чата, и это не мелочь: со встроенным в GGUF шаблоном сервер отвечает пятисоткой на каждом рабочем запросе агента. Разбор — в отдельной статье, ссылка ниже.
Накладные расходы 0.4 с. Вот что видно только на регрессионной методике: свободный член прямой у llama.cpp равен +0.39…+0.42 с, у vLLM — минус шесть миллисекунд, то есть ноль. Почти полсекунды уходит ещё до того, как движок начал считать промпт. Для чата это незаметно, для агента, который делает десятки коротких ходов подряд, — это полсекунды на каждом ходу, и в медиане хода (2.83–2.94 против 1.52 у К3) видно ровно это.
Вывод по третьей карте
Третья карта решает вопрос памяти: 262144 токена контекста без всякой экзотики вроде четырёхбитного KV, и лучший в наборе кэш префикса — он включается уже с 656 токенов и даёт до ×19 на длинном префиксе.
Скорости она не добавляет. Слот на четыре линии — жёсткий потолок, и он бьёт ровно по той метрике, которая для агента важнее всего. Загрузка карт под нагрузкой 14–21 % — это и есть картинка «ждём шину», а не «считаем».
Контекст под нагрузкой: что происходит на длинном промпте
Здесь самое интересное. Все цифры выше сняты на коротких промптах, а весь смысл большого контекста в том, что промпты бывают длинными. Лестница длин показывает, во что превращаются заявленные токены.
Читать три таблицы ниже проще всего так: первая — сколько секунд экран остаётся пустым, вторая — с какой скоростью модель читает запрос на этом отрезке, третья — с какой скоростью она печатает ответ. Промпт один и тот же, железо одно и то же, разница — только в конфигурации запуска.
Пауза до первого слова по длине промпта, секунды:
| Длина промпта | К1, 4 бита | К2, MTP 65k | К3, MTP 131k | К4, fp8171k | К5a, по слоям | К5b, по тензорам |
|---|---|---|---|---|---|---|
| 998 | 0.28 | 0.30 | 0.29 | 0.28 | 1.38 | 1.66 |
| 3871 | 1.07 | 1.10 | 1.17 | 1.09 | 2.53 | 4.11 |
| 15370 | 4.45 | 4.63 | 4.92 | 4.61 | 7.98 | 13.80 |
| 61533 | 22.07 | 25.84 | 25.52 | 23.36 | 36.77 | 58.38 |
| 123848 | 53.70 | — | 67.27 | 60.21 | 96.42 | 135.28 |
| 248380 | 149.94 | — | — | — | — | — |
Скорость чтения запроса на отрезке между соседними точками, ток/с:
| Отрезок | К1 | К2 | К3 | К4 | К5a, по слоям | К5b, по тензорам |
|---|---|---|---|---|---|---|
| до 3871 | 3635 | 3561 | 3280 | 3550 | 2515 | 1177 |
| до 15370 | 3397 | 3264 | 3065 | 3269 | 2110 | 1186 |
| до 61533 | 2621 | 2176 | 2241 | 2462 | 1603 | 1036 |
| до 123848 | 1970 | — | 1493 | 1691 | 1045 | 810 |
| до 248380 | 1294 | — | — | — | — | — |
Скорость выдачи токенов на той же длине, ток/с:
| Длина промпта | К1 | К2 | К3 | К4 | К5a, по слоям | К5b, по тензорам |
|---|---|---|---|---|---|---|
| 998 | 55.4 | 89.8 | 90.1 | 56.7 | 38.7 | 55.6 |
| 15370 | 46.5 | 93.8 | 82.8 | 55.8 | 36.3 | 52.0 |
| 61533 | 30.8 | 83.1 | 74.2 | 53.6 | 29.2 | 47.7 |
| 123848 | 21.2 | — | 76.6 | 51.1 | 26.2 | 35.9 |
| 248380 | 13.0 | — | — | — | — | — |
Четыре вывода из этих таблиц.
Первый: чтение запроса замедляется у всех, и это не аномалия конкретной сборки. На каждое удвоение длины скорость обработки входа теряет 8–19 %. Внимание квадратично по длине, и хотя у этой модели полноценное внимание всего в 16 слоях из 64, на такой длине оно всё равно берёт своё.
Второй: на длинном промпте всё время уходит на чтение запроса. Если считать холодный запрос как «пауза до первого слова плюс 300 токенов ответа», то доля чтения растёт так:
| Длина промпта | К1 | К3 | К4 | К5b, по тензорам |
|---|---|---|---|---|
| 3871 | 16 % | 26 % | 17 % | 41 % |
| 15370 | 41 % | 58 % | 46 % | 71 % |
| 61533 | 69 % | 86 % | 81 % | 90 % |
| 123848 | 79 % | 94 % | 91 % | 94 % |
| 248380 | 87 % | — | — | — |
Начиная примерно с шестидесяти тысяч токенов скорость генерации перестаёт влиять на впечатление от системы. Влияет только префилл.
Третий: угадывание наперёд окупается длиной ответа, а не длиной промпта. В прошлой редакции я писал, что спекуляция перестаёт окупаться примерно на 60 тысячах токенов. Это была неполная правда: всё зависит от того, сколько модель напишет в ответ. Считается это в лоб — MTP проигрывает на TTFT и выигрывает на каждом токене выдачи:
| Длина промпта | Проигрыш на паузе | Выигрыш на каждом токене | Ответ, с которого угадывание выгодно |
|---|---|---|---|
| 15370 | 0.31 с | 5.8 мс | от 54 токенов |
| 61533 | 2.16 с | 5.2 мс | от 420 токенов |
| 123848 | 7.06 с | 6.5 мс | от 1080 токенов |
То есть даже на промпте в 124 тысячи токенов спекуляция окупается, если ответ длиннее тысячи токенов, — а у агента, который пишет патч или разбор, так и бывает. Проигрывает MTP только в сценарии «залил огромный документ и получил абзац в ответ».
Четвёртый: угадывание наперёд держит скорость печати по всей длине. Падение печати на удвоение: ×1.01 у К2, ×1.02 у К3 и К4 против ×1.20 у К1. У К3 на 124 тысячах токенов чистая скорость печати 76.6 ток/с — выше, чем у К4 на тысяче токенов. Спекулятивное декодирование не просто ускоряет старт, оно вытягивает хвост.
Цена строгого формата: ответ по форме стоит 19 % скорости
Отдельная находка, которой не было в прошлой редакции. Последовательный тест гоняется дважды: со свободным ответом и с принуждением по JSON-схеме (response_format со strict: true). Разница — это цена грамматики, и она резко разная у конфигураций со спекуляцией и без неё:
| Конфигурация | Свободный ответ | Ответ строго по форме | Цена |
|---|---|---|---|
| К1, 4 бита, без MTP | 54.17 | 51.27 | −5.4 % |
| К4, fp8, без MTP | 55.00 | 51.11 | −7.1 % |
| К2, MTP | 119.16 | 96.01 | −19.4 % |
| К3, MTP | 122.09 | 99.67 | −18.4 % |
| К5a, llama.cpp по слоям | 51.88 | 52.89 | +1.9 % |
| К5b, llama.cpp по тензорам | 70.91 | 75.49 | +6.5 % |
Логика простая. Когда ответ обязан лечь в заданный формат, движок на каждом шаге запрещает часть вариантов — иначе схема сломается. Быстрая черновая голова про эти запреты не знает и чаще промахивается мимо разрешённого: принятых черновых токенов меньше, выигрыш от угадывания тает. Без спекуляции грамматика стоит те самые 5–7 %, которые обычно и называют «ценой structured output».
Практический вывод для агентов: если ваш клиент гоняет всё через строгие схемы, реальная разница между конфигурацией с MTP и без неё не восемьдесят процентов, а около девяноста пяти — но всё равно в пользу MTP.
У llama.cpp цифры со схемой выше, чем без неё. Это не магия грамматики, а короткие ответы: под схемой модель пишет плотнее и упирается в потолок в 512 токенов иначе. Разница в пределах разброса между запусками, читать её как «схема ускоряет» не надо.
Экономия на повторах: почему кэш префикса у vLLM включается поздно
| Длина префикса | К1 | К2 | К3 | К4 | К5a, по слоям | К5b, по тензорам |
|---|---|---|---|---|---|---|
| 656 | ×0.97 | ×0.98 | ×0.98 | ×0.97 | ×5.79 | ×5.38 |
| 1656 | ×0.98 | ×0.95 | ×0.96 | ×4.29 | ×7.93 | ×9.01 |
| 2856 | ×0.99 | ×0.97 | ×2.20 | ×2.07 | ×10.31 | ×12.10 |
| 5256 | ×2.27 | ×2.41 | ×4.66 | ×7.86 | ×13.87 | ×18.98 |
Число меньше единицы означает, что повторный запрос пришёл не быстрее холодного, то есть кэш не сработал вовсе. И вот тут видно то, о чём обычно не пишут: у vLLM кэш префикса работает блоками, и чем сильнее сжат KV, тем крупнее блок. Замеренный квант cached_tokens вышел такой: 1568 токенов у К4, около 1700 у К3, около 3100 у К1 и К2. Короткий системный промпт в такой блок не попадает целиком — и не кэшируется вообще.
Для агента это важнее, чем кажется. Типичный системный промпт клиента — 400–2000 токенов, и на конфигурации с четырёхбитным KV он не переиспользуется ни разу. У llama.cpp с --parallel 1 кэш устроен иначе и ловит префикс с 656 токенов, отсюда ×5.8 уже на первой точке.
Медианный выигрыш по всем четырём точкам — та самая цифра «×0.98» в сводной таблице — поэтому и вводит в заблуждение. Смотреть надо на длину, с которой кэш вообще включился.
Исправность сборки: не поглупела ли модель
Модель ужата почти вчетверо, и главный страх при этом один: не ушли ли вместе с размером мозги. Отдельный тест проверяет не «умная ли модель», а не сломана ли сборка: отвечает ли строго по заданной форме (structured output и шаблон чата), решает ли 25 задач с заранее известным ответом, не мешает ли языки в одном ответе и приходит ли на три одинаковых запроса один и тот же текст побайтово.
| Конфигурация | Итог | Structured output | Задачи | Язык | Повторяемость |
|---|---|---|---|---|---|
| К1, 4 бита | 100 % | 100 % | 100 % | 100 % | 100 % |
| К2, MTP 65k | 100 % | 100 % | 100 % | 100 % | 100 % |
| К3, MTP 131k | 100 % | 100 % | 100 % | 100 % | 100 % |
| К4, fp8171k | 100 % | 100 % | 100 % | 100 % | 100 % |
| К5a, llama.cpp по слоям | 100 % | 100 % | 100 % | 100 % | 100 % |
| К5b, llama.cpp по тензорам | 100 % | 100 % | 100 % | 100 % | 100 % |
В прошлой редакции у vLLM здесь было 90–95 %, и весь провал держался на одном сценарии: system-сообщение не первым в списке, роли user → system → user, ответ HTTP 400. Виноват был встроенный в чекпойнт jinja-шаблон Qwen3.8, который бросает raise_exception('System message must be at the beginning').
Теперь все четыре конфигурации vLLM запускаются с --chat-template, и сценарий проходит. Заодно исчезли редкие провалы схемы саммари, которые я в прошлый раз принял за поведение квантованной модели: они шли из того же клиента, а не из квантования.
Это, пожалуй, главный практический вывод статьи, и стоит он одного флага: никакая конфигурация не работает без исправленного шаблона чата. Клиенты кладут системное сообщение в середину диалога регулярно, и каждый такой запрос без правки отвечает четырёхсоткой. Разбор самой поломки — в статье Как запустить Claude Code локально на llama.cpp и Qwen 3.8 27B.
Готовый шаблон лежит в архиве под статьёй — кнопка «Материалы к статье» внизу страницы. Там же launch-configs.sh со всеми шестью командами запуска и замерами в комментариях, чтобы не собирать их по тексту. Файл один и тот же для NVFP4 в vLLM и для GGUF в llama.cpp, отличается только имя флага: --chat-template против --chat-template-file.
Второе: разваливающейся квантизации на этом наборе не видно ни у кого, включая четырёхбитный KV. Задачи с проверяемым ответом, языковая целостность и побайтовая повторяемость — сотня у всех шести. Качество здесь ломает не квант, а конфигурация.
Агентская траектория: 26 ходов подряд
Двадцать шесть фиксированных ходов, двадцать пять схем инструментов в каждом запросе, история растёт до 28720 токенов. Модель не управляет ходом теста — история записана заранее, ответ идёт только на вердикт. Это ближе всего к тому, как сборку нагружает реальный агент. Шесть из двадцати пяти схем — приманки: их не ждёт ни один ход, и взятая приманка однозначно означает ошибку, а не сбившийся порядок.
| Конфигурация | Верный выбор инструмента | Переиспользование кэша | Рост паузы по сессии | Обычный шаг |
|---|---|---|---|---|
| К1, 4 бита | 88.5 % | 83.2 % | ×0.54 | 2.22 с |
| К2, MTP 65k | 88.5 % | 74.0 % | ×1.04 | 1.77 с |
| К3, MTP 131k | 96.2 % | 82.4 % | ×0.93 | 1.52 с |
| К4, fp8171k | 84.6 % | 88.1 % | ×0.76 | 2.02 с |
| К5a, по слоям | 92.3 % | 93.9 % | ×0.64 | 2.83 с |
| К5b, по тензорам | 88.5 % | 93.9 % | ×0.46 | 2.94 с |
Проценты верных вызовов читать надо осторожно: 26 ходов, один ход — это 3.85 %. Разница между 84.6 и 96.2 % — три хода. Поэтому полезнее не проценты, а карта провалов:
| Ход | Что не так | К1 | К2 | К3 | К4 | К5a, по слоям | К5b, по тензорам |
|---|---|---|---|---|---|---|---|
| 7 | вместо правки файла — чтение файла | ✗ | ✗ | ✗ | |||
| 13 | то же самое | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
| 17 | вызов оборвался на потолке в 640 токенов, JSON не разобрался | ✗¹ | ✗ | ✗ | ✗ | ||
| 20 | взята приманка deploy_service вместо run_command | ✗ | ✗ | ✗ |
¹ у К1 на семнадцатом ходу другая ошибка: вызвано чтение файла вместо записи.
Отсюда три вещи. Тринадцатый ход провалили все шесть — это поведение модели, а не свойство конфигурации, и списывать его на флаги запуска нельзя. Приманку взяли три конфигурации vLLM из четырёх — все, кроме К3; на llama.cpp она не сработала ни разу. И обрыв по потолку ответа — пограничный случай самого теста, а не поломка сборки.
Чистый результат: К3 прошла всё, кроме общего тринадцатого хода — 25 из 26.
Рост TTFT меньше единицы — не ошибка. С работающим кэшем первый ход платит за холодный префилл целиком, а следующие дочитывают только новый кусок. Чем меньше число, тем лучше кэш делает свою работу. Единственное значение выше единицы — у К2, и это тот же прогон, где кэш префикса включился позже всех и переиспользование вышло 74 %.
Переиспользование кэша у llama.cpp выше всех — 93.9 % против 74–88 % у vLLM. Но медиана времени хода у него всё равно худшая: 2.8–2.9 с против 1.52 у К3. Полсекунды постоянных накладных на каждом ходу никаким кэшем не отыграть.
Как подключить агента к этим конфигурациям
Любой клиент, который умеет ходить в Anthropic API, подставляется переменными окружения. Для vLLM-конфигураций все три роли моделей указывают на одну и ту же локальную модель:
ANTHROPIC_BASE_URL=http://10.10.1.32:8000 \
ANTHROPIC_API_KEY=dummy ANTHROPIC_AUTH_TOKEN=dummy \
ANTHROPIC_DEFAULT_OPUS_MODEL=unsloth/Qwen3.8-27B-NVFP4 \
ANTHROPIC_DEFAULT_SONNET_MODEL=unsloth/Qwen3.8-27B-NVFP4 \
ANTHROPIC_DEFAULT_HAIKU_MODEL=unsloth/Qwen3.8-27B-NVFP4 \
claude
Ключи нужны любые непустые — сервер их не проверяет, но клиент без них не стартует.
Обратите внимание на --parallel 1 и урезанные батчи: сборка настроена под одного пользователя. Со стороны vLLM тому же служат --max-num-seqs 8 и небольшой --max-num-batched-tokens, а --tool-call-parser qwen3_coder и --reasoning-parser qwen3 разбирают вызовы инструментов и блоки рассуждений в формат, который клиент понимает. Флаг --enable-prompt-tokens-details нужен, чтобы движок вообще отдавал cached_tokens в usage — без него вы не увидите, работает ли кэш префикса, и метрика переиспользования останется пустой.
Ещё одна мелочь, которую легко пропустить: min_p и logit_bias со спекулятивным декодированием не работают — vLLM предупреждает об этом в логе.
Где узкое место
Если свести шесть прогонов в один ответ — узких мест четыре, и они в таком порядке.
1. Чтение запроса на длинном контексте. Это главное. Не печать ответа. На 124 тысячах токенов холодный запрос с ответом в 300 токенов занимает от 66 до 144 секунд, и от 79 до 94 % этого времени — чтение входа. Скорость префилла теряет 8–19 % на каждое удвоение длины у всех шести прогонов без исключения. Любая оптимизация, которая ускоряет выдачу и замедляет предзаполнение, на длинном промпте работает против вас — а на коротком, наоборот, окупается мгновенно.
Лечится это не флагами, а архитектурой работы: кэшем префикса, стабильным началом промпта, инкрементальными ходами вместо одного гигантского запроса. У агента с работающим кэшем префилл платится один раз, у сценария «залил документ — спросил» — каждый раз заново.
2. Скорость памяти видеокарты на печати ответа — и она наполовину лечится бесплатно. Одинаковое железо, одинаковые веса, разница только в одном флаге: 55–56 ток/с без MTP и 99–101 с MTP. Восемьдесят процентов разницы означают, что декодирование упиралось не в вычисления, а в чтение весов из VRAM — классическая стена батча единица. Косвенное подтверждение — датчики: карты берут 145–167 W из разрешённых 300.
Спекулятивное декодирование эту стену обходит: за один проход по весам проверяется несколько токенов сразу. Стоит это 6.5 % префилла и около 0.9 GiB памяти. Запускать эту модель без MTP — значит отдавать почти половину скорости даром.
3. VRAM: 32 GB на две карты, из них около 6 GB на всё, кроме весов. Это первопричина всей статьи. Не будь этого ограничения, не было бы ни четырёхбитного KV, ни выбора между vision и контекстом, ни ручной подкрутки --kv-cache-memory-bytes. Все пять конфигураций — это пять способов поделить три гигабайта на карту.
4. Шина PCIe, а конкретно — слот на четыре линии. Это то, что убивает идею «доложить третью карту». Тензорное деление между тремя картами даёт +38 % к декодированию и −44 % к префиллу, потому что каждый слой платит обменом через Gen4 x4. Послойное деление префилл спасает, но тогда карты работают по очереди — загрузка 14–21 %. Ни один из вариантов не догоняет две карты в vLLM: 2118 и 1177 против 3360 ток/с. Здесь помогла бы платформа с нормальной разводкой линий, а не ещё одна видеокарта.
Чего в списке узких мест нет:
- Качества квантования. Исправность сборки 100 % у всех шести прогонов, включая четыре бита на KV. Задачи с проверяемым ответом, язык и побайтовая повторяемость — сотня везде.
- Шаблона чата. Он ломал всё в прошлой редакции и чинится одним флагом.
- Процессора. Ryzen 5 3600 — самое старое звено сборки, но веса лежат в VRAM, и в замерах его вклад виден только косвенно, через накладные расходы движка.
Что я выбрал под какие задачи
Ежедневная работа с кодом и агентом — конфигурация 3. MTP, fp8-кэш, mamba в bf16, --language-model-only, 131072 токена. Она выигрывает у соседей по всему, что чувствуется руками: выдача 102.6 ток/с против 56, время до первого токена 0.057 с, медианный ход агента 1.52 с — на 25 % быстрее ближайшего, и лучшая точность вызова инструментов (25 ходов из 26). Контекста 131 тысяча токенов, и на практике агент столько не набирает: он работает серией запросов с кэшем префикса, а не одним гигантским промптом. Это дефолт, с которого стоит начинать.
Картинки и мультимодальность — конфигурация 2. Единственная, где модель поднимается целиком. Скорость почти та же (98.6 против 100.7), контекста вдвое меньше, кэш префикса включается позже и переиспользуется хуже. Брать её имеет смысл ровно тогда, когда нужен vision; в остальных случаях конфигурация 3 её перекрывает по всем осям.
Один большой документ на 130–250 тысяч токенов — конфигурация 1. Единственная, где замером подтверждены 248380 токенов: иголка найдена на всех трёх глубинах четверти миллиона. Но это пакетный режим, а не интерактивный: 150 секунд до первого токена, 13 ток/с выдачи, кэш префикса не работает на промптах короче 5 тысяч токенов. Поставили задачу — ушли пить чай.
Конфигурация 4 — запасной вариант, а не рабочий. По префиллу она равна третьей, по скорости выдачи вдвое медленнее, по точности вызова инструментов худшая в наборе. Смысл в ней остаётся в двух случаях: если MTP в вашей сборке ведёт себя нестабильно, и если нужен честный контекст между 131 и 171 тысячей токенов на восьмибитном KV. Ещё у неё лучший среди vLLM кэш префикса — включается с 1656 токенов, — и это единственная метрика, где она обходит третью.
Полные 262144 токена без экзотики — llama.cpp на трёх картах. Если NVFP4 не вариант (старые карты), или в машине уже стоит 3090, которую жалко не использовать. Из двух режимов деления: layer — когда важнее время до ответа (префилл вдвое выше), tensor — когда важнее скорость печати (декодирование на 38 % выше). Для агента я бы не брал ни тот, ни другой: полсекунды постоянных накладных на каждом ходу превращаются в полторы минуты на сотне ходов.
И общая рекомендация вне зависимости от выбора, из двух пунктов:
- Запускайте с починенным шаблоном чата —
--chat-templateу vLLM,--chat-template-fileу llama.cpp. Без него ломается не скорость, а работоспособность. - Включайте MTP везде, где он не конфликтует с квантованием KV. Плюс 80 % к выдаче за 6.5 % префилла — лучший размен во всей статье.
Что осталось непроверенным
- Перплексию четырёхбитного KV не мерил: только тесты исправности сборки и поиск иголки. Цифра +2.71 % взята из кода vLLM, а не из моего замера.
- Лестница контекста у К5 остановилась на 123848 токенах не из-за движка, а из-за двадцатиминутного бюджета теста: точке 262144 требовалось ещё 14–20 минут. Что там с иголкой на полной длине в llama.cpp, я не знаю.
- Конфигурацию «четыре бита плюс MTP» я не публикую сознательно: она собирается и печатает мусор, разбор — в разделе про спекулятивное декодирование.
- Все замеры сняты батчем 1, параллельные тесты пропущены методикой как бессмысленные при
parallel = 1. Под несколькими одновременными запросами выигрыш спекулятивного декодирования обычно тает — на этом стенде не проверял. - Квант GGUF движок называет
Q4_K - Mediumпри имени файлаUD-Q8_K_XL. Реальную раскладку разрядностей по слоям я не разбирал.
Про соседние стенды я писал отдельно: Qwen 3.6 27B и 35B-A3B в NVFP4 — сравнение vLLM и SGLang на этих же картах, vLLM vs llama.cpp — что выбрать под задачу, RTX 5070 Ti vs RTX 3090 — стоит ли брать новое поколение, и GPU-сервер для ИИ дома — как собиралась сама машина. Замеры скоростей на разных картах лежат в бенчмарках.
Если хотите собрать такой же стенд и не наступить на те же грабли — про выбор карт, локальные модели и сборку агентов вокруг них на реальных задачах бизнеса я разбираю на курсе ИИ для 1С: там же и про то, как посадить локальную модель в рабочий контур, а не в демо.
