Ну чё, малютки, признавайся: temperature=0.7, top_p=0.95 — это заклинание, которое ты скопировал из чужого конфига, потому что «ну все так пишут»? Никто не спорит, работает. Но если спросить, почему именно 0.7, а не 0.6, что вообще такое top_p=0.95 и что случится, если добавить ещё и min_p — начинается мычание. А зря: за каждой из этих ручек стоит конкретная, совсем не мистическая операция над массивом чисел. И самое интересное — что «интуитивный» порядок применения этих ручек, который знает большинство людей, писавших с HuggingFace, в vLLM просто не работает: там ручки крутятся в другой последовательности, и на одном и том же конфиге ты можешь получить разное распределение вероятностей в зависимости от того, что у тебя под капотом сервера.
Модель на каждом шаге выдаёт логиты — сырые числа на весь словарь. temperature делит их внутри softmax, управляя остротой итогового распределения. top_k и top_p после этого обрезают хвост — по рангу и по накопленной массе соответственно, но оба глухи к тому, насколько модель уверена. min_p — младше по возрасту, но умнее: порог считается от вероятности лидера, поэтому подстраивается под форму распределения сам. Порядок этих ручек не единый стандарт: vLLM и HuggingFace применяют их по-разному, и один и тот же конфиг может дать разный результат. А в конце — твист: temperature=0 не спасает от недетерминизма, и дело не в том, что ты подумал.
Часть 1: откуда вообще берутся вероятности
Модель не выбирает слово. На каждом шаге генерации она считает один forward pass и на выходе получает вектор логитов — по одному сырому числу на каждый токен словаря (обычно 30–150 тысяч чисел). Логит — это не вероятность и даже не что-то отдалённо похожее на неё: это просто скаляр, и он может быть отрицательным, может быть больше 100, единственное, что имеет смысл — сравнение логитов друг с другом. Чтобы превратить эту кучу чисел в распределение вероятностей, которое честно суммируется в единицу, применяют softmax:
Вот тут и заходит temperature. Она не «добавляет случайности» и не «включает креативность» — она просто делит логиты на число T до того, как они попадают в softmax:
Разница на вид крошечная, а по эффекту — принципиальная. Возьми два логита, 3.2 и 2.9 (разница 0.3). При T=1 после softmax они дадут что-то вроде 57% и 43% — распределение мягкое. При T=0.3 те же логиты после деления превращаются в 10.7 и 9.7 — разница выросла в разы, softmax от них выдаёт уже 73% и 27%. При T→0 деление устремляет любую ненулевую разницу логитов к бесконечности, softmax вырождается в честный argmax: у лидера 100%, у всех остальных — 0%. При T>1 наоборот, логиты сжимаются друг к другу, распределение расплющивается к равномерному — модели становится всё равно, что говорить.
Так что temperature — это буквально острота распределения (по-английски её называют sharpness/peakedness), а не «творческий потенциал». «Модель на 0.9 креативнее» — не техническое утверждение, а маркетинговый пересказ того, что низковероятные токены получают чуть больше шанса выстрелить.
Не верь на слово — посмотри на сами числа. Один набор логитов, один ползунок: слева то, что происходит с ними до softmax, справа — после.
Часть 2: зачем резать хвост, и как это делают top-k и top-p
Проблема сырого сэмплирования из полного распределения в том, что у словаря в 100+ тысяч токенов хвост длинный. Отдельный «ассемблер» вместо «коврика» в предложении «кот сидит на ___» весит доли процента — но таких безумных продолжений в хвосте тысячи, и суммарно они наберут заметную массу. Время от времени модель споткнётся о шум просто потому, что кости так легли. Отсюда — резать хвост до сэмплирования.
top_k — самый тупой и самый старый способ: оставить только k токенов с наибольшими логитами, всё остальное занулить. По реализации это не «топ-k самых вероятных», а буквально ранг: сортируем логиты, берём k-й по величине как порог, и маскируем всё, что строго меньше этого порога. Тонкость в этом «строго»: если у нескольких токенов логиты совпали тик-в-тик (такое бывает редко, но бывает), выживших окажется больше, чем k — все, у кого логит ровно как у k-го места, проходят тоже. top_k не смотрит на форму распределения: что на уверенном «Столица Франции — ___», что на размазанном «в комнате было ___», он всегда оставит одно и то же число кандидатов.
top_p (nucleus sampling) устроен умнее: сортируем токены по вероятности, идём от самого вероятного вниз, копим сумму — и останавливаемся, как только накопленная масса дотягивает до порога p. Набор получается минимальным по размеру, но не фиксированным по количеству: на уверенном распределении, где лидер держит 90%, до порога 0.9 может хватить одного-двух токенов. На размазанном, где вероятность растянута плоско по десяткам кандидатов, для той же массы 0.9 придётся набрать в разы больше токенов. Два инварианта, которые стоит запомнить: токен, из-за которого сумма перевалила порог, всё равно включается целиком (никто не режет вероятность по живому), и лидер распределения переживает top_p всегда — при любом p > 0 он входит в первую же добавленную единицу массы.
Важная деталь про то, как это всё склеивается воедино. Может показаться логичным, что после top_k пересчитывается распределение, потом режется top_p по новому распределению, потом снова пересчитывается — маленький конвейер с перенормировкой на каждом шаге. Это не так, по крайней мере в vLLM. Промежуточной перенормировки нет: softmax внутри top_p действительно считается по ходу дела — иначе не из чего накапливать массу до порога p, — но это черновой, служебный softmax только ради накопленной суммы и порога отсечения. Финальное же распределение, из которого реально сэмплится токен, получается одной softmax по логитам, где непрошедшие ручки токены уже помечены как −∞, единым проходом в конце.
Дефолты SamplingParams в vLLM: temperature=1.0, top_p=1.0, top_k=0 (и 0, и −1 означают «не ограничивать» — не «оставить ноль токенов», это частая опечатка в голове), min_p=0.0. То есть из коробки, без явной настройки, движок сэмплирует из полного распределения без всякой обрезки хвоста.
top_k=0 означает «не ограничивать» — это дефолт vLLM, как и top_p=1.0 и min_p=0. Порядок ручек не косметика: min_p считает порог от лидера до того, как top_k и top_p что-то отрежут.Покрути ручки на живом распределении — видно, что top_k и top_p режут по формальному критерию (ранг, накопленная масса), а не по тому, уверена модель или мечется. Дальше становится ещё интереснее: после того как ручки решили, кто выживает, финальный выбор токена — это не гарантированная победа самого вероятного, а честный бросок костей по оставшимся весам.
Часть 3: min-p — порог, который умеет думать
top_k и top_p объединяет один изъян: оба принимают решение по абсолютным критериям (ранг, накопленная масса), совершенно не глядя на то, насколько модель вообще уверена в своём выборе. Фиксированный top_k=40 на уверенном распределении, где реально имеет смысл один-два токена, пропустит мусор в оставшихся тридцати восьми местах. А на размазанном распределении, где кандидатов действительно много, тот же top_k=40 может отрезать что-то разумное просто потому, что оно оказалось 41-м по рангу.
min-p решает это иначе: порог считается не абсолютным числом, а долей от вероятности лидера.
где вероятности берутся после применения температуры (то есть min_p стоит уже по ту сторону остроты распределения, которую задал T). Всё, что строго меньше порога, — под нож. Красота в том, что порог автоматически съезжает вместе с формой распределения: на уверенном «Столица Франции — ___» лидер держит 90%, при min_p=0.1 порог — 0.09, и почти все конкуренты ниже него вылетают. На размазанном «в комнате было ___» лидер держит условные 17%, порог падает до 0.017 — и остаётся живым куда больше кандидатов, потому что относительно скромного лидера они уже не такие слабые. Один и тот же параметр — разное число выживших, в зависимости от того, уверена модель или нет.
История у min-p короче, чем можно подумать, и стоит рассказать её точно, без прикрас. Идею предложил kalomaze в llama.cpp — PR #3841, открыт 28 октября 2023-го, влит 31 октября. Три дня от идеи до мержа в open-source движок, которым тогда уже гоняли модели тысячи людей. HuggingFace добавили min-p к себе позже, и в исходниках transformers в благодарностях за реализацию стоят @menhguin и @kalomaze. Академическое оформление подоспело позже: препринт arXiv 2407.01082, «Turning Up the Heat: Min-p Sampling for Creative and Coherent LLM Outputs» (Coherent, не Diverse — заголовок легко перепутать), авторы Nguyen, Baker, Neo, Roush, Kirsch, Shwartz-Ziv — и в итоге oral-доклад на ICLR 2025. От PR в репозитории до препринта — примерно восемь месяцев, до oral на ICLR — уже полтора года, и в этой гонке комьюнити пришло первым. Это не единичный случай: в статье про RoPE я разбирал ровно такую же рифму — NTK-aware интерполяцию тоже сперва запостили на Reddit, а не в рецензируемый сборник.
Вот тут самое неочевидное во всей статье. «Естественный» порядок, который закрепился в голове у большинства (temperature → top_k → top_p → min_p), — это порядок HuggingFace. Он логичен: сначала размять остроту, потом грубая обрезка по рангу, потом обрезка по массе, и min_p — последний штрих. Но vLLM применяет ручки в другом порядке: temperature → min_p → top_k → top_p, и только затем — единая финальная softmax. min_p там отрабатывает сразу после температуры и сразу срезает хвост — а top_p потом копит накопленную массу уже по перенормированному, «раздутому» остатку и добирает свой порог быстрее, оставляя меньше токенов. В HuggingFace наоборот: top_p идёт первым по нетронутому распределению и набирает кандидатов больше, а поздний min_p умеет только дорезать, но не возвращать срезанное. Иногда он снимает лишнее, и наборы сходятся; а иногда лишний токен садится ровно на порог и выживает.
Практический вывод жёсткий: один и тот же набор параметров может дать на vLLM и на HuggingFace разные распределения токенов — не всегда, но всякий раз, когда конфиг балансирует на границе отсечки. «Общепринятого» порядка не существует, есть порядок конкретного движка, и его надо смотреть в исходниках, а не в чужом объяснении на форуме.
Вот расхождение на дефолтных значениях: для чистоты сравнения top_k здесь выключен, и наборы расходятся только за счёт min_p и top_p.
min_p и top_p прогоняются через два конвейера в разном порядке. Дефолтные значения уже расходятся — подвигай ползунки и посмотри, когда порядок вообще не важен, а когда важен критически. Распределение иллюстративное и подобрано так, чтобы расхождение было видно сразу, — механика обоих конвейеров настоящая.min_p отрабатывает первым (vLLM), он сразу срезает хвост — и top_p потом считает накопленную массу уже по перенормированному, «раздутому» остатку: порог набирается быстрее, и в живых остаётся меньше токенов. В HuggingFace top_p идёт первым и считает по нетронутому распределению — набирает больше кандидатов, а поздний min_p умеет только дорезать, но не возвращать срезанное: иногда он снимает лишнее и наборы сходятся, а иногда лишний токен садится ровно на порог и выживает. Заметь: дело не в том, что порог min_p «становится строже» от перенормировки — сам порог к ней нечувствителен, он масштабируется вместе с вероятностями и сравнение не меняется. Расходится именно top_p, потому что ему достаётся разная стартовая масса для накопления.И раз уж заговорили про движки: официальный OpenAI API из всей этой четвёрки знает только про temperature и top_p. Ни top_k, ни min_p там нет — это расширения OpenAI-совместимого API, которые vLLM выставляет поверх формального стандарта (и переняли некоторые другие инференс-движки). Если пишешь код, который должен одинаково работать и на OpenAI-совместимом API, и на голом vLLM, — про top_k/min_p можно забыть на первом и не забыть на втором.
Часть 4: что ставить и как это сочетается с соседями
Универсального «правильного» конфига нет, но есть направления, откуда плясать:
- Код и детерминированные задачи — низкая температура (0.0–0.3) или прямой greedy. Задача с одним правильным ответом не выигрывает от того, что модель иногда решит подумать нестандартно — это просто источник багов.
- JSON и structured outputs — та же логика: низкая температура, скупая обрезка хвоста. Здесь важнее не «разнообразие», а стабильное попадание в грамматически валидный токен.
- Творческий текст — высокая температура вместе с min-p. Это ровно тезис пейпера про min-p: при высокой T обычный top-p либо режет слишком консервативно на уверенных шагах, либо пропускает мусор на неуверенных, а min-p, подстраивающий порог под форму распределения, даёт разнообразие без скатывания в бред.
Теперь сложи все ручки в один конвейер и посмотри на всю воронку сразу — от полного словаря до одного выбранного токена.
Два взаимодействия, о которых легко забыть, потому что они происходят не «рядом» с сэмплингом, а внутри него.
Structured outputs. В статье про structured outputs разбирался механизм: грамматика компилируется в конечный автомат, и на каждом шаге автомат отдаёт маску допустимых токенов, которая накладывается на логиты — невалидные получают −∞ ещё до сэмплирования. В конвейере vLLM это маскирование стоит в самом начале, перед temperature/min_p/top_k/top_p. Из этого следует не «фича повлияет на фичу», а спокойный вывод: сэмплинг всегда работает по уже урезанному грамматикой набору. Если по схеме валиден только один токен — все твои ручки бессмысленны, выбирать не из чего. Если валидны несколько — ручки продолжают решать между ними как обычно, просто на меньшей арене.
Спекулятивное декодирование. В статье про спекулятивный декодинг я показывал, что механизм гарантирует то же самое распределение, что дала бы целевая модель без черновика — но только при обычном сэмплировании, включая greedy как частный случай T→0. Разбора top-k/top-p/min-p там не было — тема была не про это. Но оттуда мы знаем, что acceptance rate (как часто целевая модель соглашается с черновиком) падает именно на творческих задачах с высокой температурой — там у целевого распределения много правдоподобных продолжений, и черновику труднее попасть в токен, который реально выпадет. Отсюда практический стык: агрессивная обрезка хвоста (низкий top_p, ощутимый min_p) сужает пространство «правдоподобных» продолжений и тем самым облегчает жизнь черновику — спекулятивный декодинг и жёсткий сэмплинг дружат, а высокая температура с широким хвостом — естественные враги скорости.
Есть и экзотика за пределами этой четвёрки — repetition/frequency penalty, DRY, XTC, typical-p, mirostat — каждый со своей идеей, как ещё покрутить распределение перед выбором токена; в эту статью они не поместились, это тема для отдельного разбора.
Часть 5: почему temperature=0 не спасает от недетерминизма
Раз argmax — это честный «всегда бери самое вероятное», должно быть логично, что temperature=0 гарантирует одинаковый ответ на одинаковый запрос. Но сначала — что вообще происходит с T=0 технически, потому что тут два движка ведут себя по-разному. Формула температуры — деление логитов на T, и при T=0 это буквально деление на ноль. vLLM это ловит спец-кейсом: temperature=0 — не «деление на очень маленькое число», а отдельная ветка кода, которая сразу уходит в честный argmax, вообще минуя деление. Это не то же самое, что защита от почти-нуля: для значений в диапазоне (0, 0.01), которые уже не ноль, но опасно близко, vLLM тихо поднимает их до 0.01 и считает дальше обычным (просто очень острым) softmax — это другая ветка, не argmax. HuggingFace поступает иначе: с temperature=0 он бросает ValueError и текстом говорит, что для детерминированной генерации нужно do_sample=False, а не подделывать её через ноль в температуре.
Ладно, допустим argmax — это надёжно, каждый раз берём самый вероятный токен, откуда взяться разнице. А разница берётся не из сэмплинга — она берётся до него, из того, как считаются сами логиты.
a = -992.28125
b = -0.002584039466455579
c = 8535.2998046875
(a + b) + c = 7543.01611328125
разница между порядками: 0.00048828125| как считали | токен «a» | токен «b» | argmax |
|---|---|---|---|
| float64 (эталон) | 80.36561892 | 80.36568176 | b |
| float32, последовательно | 80.36564 | 80.3654 | a |
| float32, попарно / чанками | совпало с эталоном | b | |
Здесь легко соскользнуть в объяснение «ну это просто floating point, сложение не ассоциативно, чего ты хотел» — и это объяснение не то чтобы неверное, но неполное, и именно эту неполноту в сентябре 2025-го разобрали Horace He с коллегами из Thinking Machines в статье «Defeating Nondeterminism in LLM Inference» (10 сентября 2025). Их формулировка дословная: популярное объяснение через неассоциативность float и конкурентность на GPU «not entirely wrong… doesn’t reveal the full picture» — не то чтобы неправильное, но не раскрывает всей картины. И они это доказывают экспериментально: одно и то же матричное умножение, повторённое много раз на одном и том же GPU, даёт битово идентичный результат каждый раз. Если бы дело было в неупорядоченной гонке потоков самой по себе, повторный прогон плавал бы. Он не плавает.
Настоящая причина глубже: у ядер (RMSNorm, matmul, attention) на GPU нет batch invariance. Оптимальная стратегия редукции — как именно раскладывать сумму по потокам и в каком порядке её потом складывать обратно — зависит от размера батча, в котором прилетел твой запрос: другой размер батча — другая раскладка на GPU — другой порядок суммирования floating-point чисел — и, как показывает демка выше, порядок суммирования может физически изменить число, которое ты получаешь на выходе. А размер батча, в котором окажется твой конкретный запрос на публичном инференс-сервере, зависит от того, кто ещё в этот момент стучится в тот же эндпоинт — то есть от нагрузки, которая по определению не под твоим контролем и меняется от секунды к секунде. Значит, даже строгий argmax по каждому шагу генерации может в отдельных запросах упереться в токен, чей логит на волосок обошёл соседа — а «на волосок» решается порядком суммирования, а он решается тем, кто попал в твой батч.
Лечится это не смирением, а инженерно: batch-invariant ядра — реализации RMSNorm, matmul и attention, которые дают одинаковый порядок редукции независимо от размера батча. Именно это и предлагает статья Thinking Machines, показывая, что при таких ядрах повторный запрос с одинаковым сидом и argmax действительно даёт битово одинаковый результат — при прочих равных условиях.
Практический вывод холодный: на своём железе, с фиксированным батчем и batch-invariant ядрами, воспроизводимость реально достижима — это инженерная задача, не миф. А через публичный API, где батч формируется динамически из чужих запросов, — нет, и temperature=0 эту проблему не решает, потому что она не в сэмплировании, она уровнем ниже.
TL;DR
min_p × p_max), поэтому подстраивается: на уверенном распределении режет жёстко, на размазанном отпускаетРучки сэмплинга — это не магические слова из чужого конфига, а четыре конкретные операции над массивом чисел, у каждой из которых есть формула и порядок применения. Скопировать temperature=0.7, top_p=0.95 можно и не задумываясь — работает же. Но как только конфиг перестаёт работать («модель то умная, то несёт чушь», «на другом движке ведёт себя иначе», «temperature=0, а ответы всё равно скачут») — карго-культ заканчивается ровно там, где начинается вопрос «а что здесь вообще происходит с числами». 🫡
Источники
- Turning Up the Heat: Min-p Sampling for Creative and Coherent LLM Outputs — Nguyen et al. (arXiv:2407.01082)
- llama.cpp PR #3841 — min-p sampling (kalomaze)
- Defeating Nondeterminism in LLM Inference — Horace He et al., Thinking Machines
- vLLM, исходники:
vllm/v1/sample/sampler.pyиvllm/v1/sample/ops/topk_topp_sampler.py - HuggingFace
transformers, исходники:generation/logits_process.py - Structured Outputs: как получить от LLM валидный JSON
- Спекулятивный декодинг: ускоряем LLM в 2–3 раза
- Токенизация: почему русский текст дороже английского
- KV-кэш: почему LLM помнит без памяти и жрёт VRAM
- Позиционное кодирование: RoPE, YaRN и почему Inkling от них отказался


