Все статьи
Артемий Мазаев·· 15 мин чтения

Ручки сэмплинга: почему temperature=0.7 — это карго-культ 🎛🎲

Что на самом деле делают temperature, top-k, top-p и min-p с распределением вероятностей, в каком порядке они применяются (в vLLM и в HuggingFace он разный), что ставить под код, JSON и творчество — и почему temperature=0 всё равно не даёт детерминизма.

Ручки сэмплинга: почему temperature=0.7 — это карго-культ 🎛🎲

Ну чё, малютки, признавайся: temperature=0.7, top_p=0.95 — это заклинание, которое ты скопировал из чужого конфига, потому что «ну все так пишут»? Никто не спорит, работает. Но если спросить, почему именно 0.7, а не 0.6, что вообще такое top_p=0.95 и что случится, если добавить ещё и min_p — начинается мычание. А зря: за каждой из этих ручек стоит конкретная, совсем не мистическая операция над массивом чисел. И самое интересное — что «интуитивный» порядок применения этих ручек, который знает большинство людей, писавших с HuggingFace, в vLLM просто не работает: там ручки крутятся в другой последовательности, и на одном и том же конфиге ты можешь получить разное распределение вероятностей в зависимости от того, что у тебя под капотом сервера.

🔥Суть за 10 секунд

Модель на каждом шаге выдаёт логиты — сырые числа на весь словарь. temperature делит их внутри softmax, управляя остротой итогового распределения. top_k и top_p после этого обрезают хвост — по рангу и по накопленной массе соответственно, но оба глухи к тому, насколько модель уверена. min_p — младше по возрасту, но умнее: порог считается от вероятности лидера, поэтому подстраивается под форму распределения сам. Порядок этих ручек не единый стандарт: vLLM и HuggingFace применяют их по-разному, и один и тот же конфиг может дать разный результат. А в конце — твист: temperature=0 не спасает от недетерминизма, и дело не в том, что ты подумал.


Часть 1: откуда вообще берутся вероятности

Модель не выбирает слово. На каждом шаге генерации она считает один forward pass и на выходе получает вектор логитов — по одному сырому числу на каждый токен словаря (обычно 30–150 тысяч чисел). Логит — это не вероятность и даже не что-то отдалённо похожее на неё: это просто скаляр, и он может быть отрицательным, может быть больше 100, единственное, что имеет смысл — сравнение логитов друг с другом. Чтобы превратить эту кучу чисел в распределение вероятностей, которое честно суммируется в единицу, применяют softmax:

pi=exp(zi)jexp(zj)p_i = \frac{\exp(z_i)}{\sum_j \exp(z_j)}

Вот тут и заходит temperature. Она не «добавляет случайности» и не «включает креативность» — она просто делит логиты на число T до того, как они попадают в softmax:

pi=exp(zi/T)jexp(zj/T)p_i = \frac{\exp(z_i / T)}{\sum_j \exp(z_j / T)}

Разница на вид крошечная, а по эффекту — принципиальная. Возьми два логита, 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, справа — после.

🌡 Форма распределения
Один набор логитов для подсказки «На улице сегодня ___» (токены и цифры иллюстративные). Слева — логиты, поделённые на температуру, справа — то, что из них делает softmax. Крути ползунок и смотри, как левая шкала растягивается или сжимается, а правая — заостряется или расплющивается вслед за ней.
логит / T
солнечно
2.8
тепло
2.5
ветрено
1.9
пасмурно
1.0
дождливо
0.4
снежно
-0.6
шторм
-1.8
вероятность
солнечно
40.9%
тепло
30.3%
ветрено
16.6%
пасмурно
6.8%
дождливо
3.7%
снежно
1.4%
шторм
0.4%
temperature1.0
Лидер «солнечно» обходит второе место «тепло» в 1.3× по вероятности — это и есть острота распределения. При T → 0 это отношение улетает в бесконечность, при большой T тянется к единице (оба варианта становятся почти равновероятны).
Слева — не проценты, а сырые числа, и они честно бывают отрицательными: шкала центрирована на нуле, зелёное — больше нуля, красное — меньше. Температура не «добавляет случайности» — она делит логиты на T ещё до 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. То есть из коробки, без явной настройки, движок сэмплирует из полного распределения без всякой обрезки хвоста.

🎛 Песочница сэмплинга
«Кот сидит на ___». Крути ручки и смотри, что происходит с распределением: кто выжил, кого какая ручка убила и какие вероятности стали у выживших после перенормировки. Логиты иллюстративные, механика настоящая.
порядок как в vLLM: temperature → min_p → top_k → top_p → softmax
коврике
34.3%
диване
25.4%
окне
15.4%
крыше
8.4%
столе
6.3%
полу
4.2%
дереве
2.5%
заборе
1.7%
ветке
1.0%
капоте
0.6%
луне
0.2%
ассемблере
0.0%
temperature1.0
min_p0.00
top_koff
top_p1.00
Выжило токенов: 12 из 12. Обрати внимание: top_k=0 означает «не ограничивать» — это дефолт vLLM, как и top_p=1.0 и min_p=0. Порядок ручек не косметика: min_p считает порог от лидера до того, как top_k и top_p что-то отрежут.

Покрути ручки на живом распределении — видно, что top_k и top_p режут по формальному критерию (ранг, накопленная масса), а не по тому, уверена модель или мечется. Дальше становится ещё интереснее: после того как ручки решили, кто выживает, финальный выбор токена — это не гарантированная победа самого вероятного, а честный бросок костей по оставшимся весам.

🎲 Сэмплирование — это бросок костей
Ручки решают, кто вообще участвует. Дальше токен выбирается случайно, с весом своей вероятности. Жми — риска показывает истинную вероятность, столбик — накопленную частоту. Чем больше бросков, тем ближе одно к другому. Веса здесь иллюстративные и уже после обрезки хвоста — как в песочнице выше, для наглядности, а не реальные логиты модели.
коврике
0 · 0.0% / 45%
диване
0 · 0.0% / 28%
окне
0 · 0.0% / 15%
крыше
0 · 0.0% / 8%
столе
0 · 0.0% / 4%
бросков: 0
Именно поэтому один и тот же запрос даёт разные ответы: модель не «передумала», просто кости легли иначе. Последовательность здесь детерминированная (фиксированный сид) — у тебя и у соседа она совпадёт.

Часть 3: min-p — порог, который умеет думать

top_k и top_p объединяет один изъян: оба принимают решение по абсолютным критериям (ранг, накопленная масса), совершенно не глядя на то, насколько модель вообще уверена в своём выборе. Фиксированный top_k=40 на уверенном распределении, где реально имеет смысл один-два токена, пропустит мусор в оставшихся тридцати восьми местах. А на размазанном распределении, где кандидатов действительно много, тот же top_k=40 может отрезать что-то разумное просто потому, что оно оказалось 41-м по рангу.

min-p решает это иначе: порог считается не абсолютным числом, а долей от вероятности лидера.

threshold=min_p×max(p1,,pn)\text{threshold} = \text{min\_p} \times \max(p_1, \ldots, p_n)

где вероятности берутся после применения температуры (то есть min_p стоит уже по ту сторону остроты распределения, которую задал T). Всё, что строго меньше порога, — под нож. Красота в том, что порог автоматически съезжает вместе с формой распределения: на уверенном «Столица Франции — ___» лидер держит 90%, при min_p=0.1 порог — 0.09, и почти все конкуренты ниже него вылетают. На размазанном «в комнате было ___» лидер держит условные 17%, порог падает до 0.017 — и остаётся живым куда больше кандидатов, потому что относительно скромного лидера они уже не такие слабые. Один и тот же параметр — разное число выживших, в зависимости от того, уверена модель или нет.

⚔️ top-p против min-p
Одно и то же значение ручки на двух распределениях: слева модель уверена, справа мечется. Переключай ручку и смотри, как меняется число выживших. Вот тут и видно разницу в характере.
Уверенное распределение
«Столица Франции — ___»
Париж
90.0
Лион
4.0
Марсель
2.0
Ницца
1.5
Тулуза
1.0
Брест
0.8
банан
0.4
ассемблер
0.3
выжило: 3 из 8
Размазанное распределение
«В комнате было ___»
тихо
17.0
странно
16.0
весело
15.0
грустно
14.0
душно
13.0
светло
12.0
пусто
8.0
вязко
5.0
выжило: 7 из 8
top_p0.95
top_p считает накопленную массу: даже когда лидер держит 90%, порог 0.95 одним токеном не наберётся — «дособрать» массу приходится из хвоста, и это его слабое место. На размазанном распределении та же логика пропускает почти всё. min_p считает порог от лидера, поэтому на уверенном режет жёстко, а на размазанном отпускает. Одно значение — разное поведение, и в этом весь смысл.

История у 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.

🥊 vLLM против HuggingFace: один конфиг, два набора
«Новый движок инференса работает ___». Одни и те же min_p и top_p прогоняются через два конвейера в разном порядке. Дефолтные значения уже расходятся — подвигай ползунки и посмотри, когда порядок вообще не важен, а когда важен критически. Распределение иллюстративное и подобрано так, чтобы расхождение было видно сразу, — механика обоих конвейеров настоящая.
min_p0.15
top_p0.92
top_k: выключен (зафиксирован — чтобы сравнивать только эффект порядка min_p и top_p); temperature уже учтена — распределение ниже дано после неё
⚡ наборы разошлись: «странно» выжил в HuggingFace и погиб в vLLM
vLLM
temperature → min_p → top_k → top_p → softmax
быстро
40%
стабильно
30%
неплохо
20%
странно
6%
ужасно
4%
выжило: 3 из 5
HuggingFace
temperature → top_k → top_p → min_p
быстро
40%
стабильно
30%
неплохо
20%
странно
6%
ужасно
4%
выжило: 4 из 5
Механика та же самая на обоих движках, дело только в очерёдности. Когда 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, подстраивающий порог под форму распределения, даёт разнообразие без скатывания в бред.

Теперь сложи все ручки в один конвейер и посмотри на всю воронку сразу — от полного словаря до одного выбранного токена.

🔻 Воронка сэмплинга целиком
Один шаг генерации — от полного словаря до одного выбранного токена. Ничего нового: это те же четыре ручки, только собранные в один конвейер, чтобы увидеть всю форму сразу. Размер словаря и числа кандидатов ниже — иллюстративные, не измерены на реальном форвард-пассе: точные значения зависят от логитов конкретной модели на конкретном шаге, которых у нас нет. Порядок ручек и характер их эффекта — настоящие.
порядок как в vLLM: словарь → temperature → min_p → top_k → top_p → финальная softmax → 1 токен
Пресеты иллюстративные — не production-конфиг, а пример того, как разный набор ручек по-разному режёт хвост.
temperature
0.2
min_p
0.10
top_k
20
top_p
0.90
Словарь128 000 токенов
После temperature128 000
⚠️Кандидатов ровно столько же, сколько в словаре — 128 000. Temperature переформовывает распределение (у кого-то вероятность выросла, у кого-то упала), но никого не выбрасывает. Резать хвост — работа min_p, top_k и top_p, которые идут дальше.
После min_p15
После top_k15
После top_p6
Выбран токен1
Размер словаря (128 000 токенов) — иллюстративный, ориентир по порядку величины для современных LLM. Числа кандидатов на каждой ступени — смоделированы для наглядности, а не измерены на реальном форвард-пассе: чтобы получить точные значения, нужны логиты конкретной модели на конкретном шаге. Но порядок ручек и то, какая из них на что похожа по эффекту — min_p подстраивается под форму распределения, top_k — жёсткий потолок по рангу, top_p — по накопленной вероятностной массе — реальные, из частей 1–3 этой статьи.

Два взаимодействия, о которых легко забыть, потому что они происходят не «рядом» с сэмплингом, а внутри него.

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 — это надёжно, каждый раз берём самый вероятный токен, откуда взяться разнице. А разница берётся не из сэмплинга — она берётся до него, из того, как считаются сами логиты.

💥 Один и тот же вход, разный ответ
Сложение float неассоциативно: переставь скобки — получишь другое число. Ниже это считается прямо в твоём браузере во float32.
переставь скобки
a = -992.28125 b = -0.002584039466455579 c = 8535.2998046875 (a + b) + c = 7543.01611328125 разница между порядками: 0.00048828125
а теперь то же самое на логитах
как считалитокен «a»токен «b»argmax
float64 (эталон)80.3656189280.36568176b
float32, последовательно80.3656480.3654a
float32, попарно / чанкамисовпало с эталономb
Скалярные произведения размерности 8192, истинный зазор между кандидатами — около 0.00006. Порядок суммирования изменился — и argmax перевернулся. Числа в таблице получены отдельным прогоном на Python (не в браузере). Но сам по себе float — только нижний слой. Чтобы недетерминизм стал наблюдаемым, порядок редукции должен меняться от запроса к запросу — а он меняется вместе с размером батча. Об этом — в тексте под компонентом.

Здесь легко соскользнуть в объяснение «ну это просто 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

1temperature — это острота распределения, а не «креативность»: логиты делятся на T ещё до softmax
2top_k режет по рангу, top_p — по накопленной массе; оба неадаптивны к тому, насколько модель уверена
3min_p берёт порог от лидера (min_p × p_max), поэтому подстраивается: на уверенном распределении режет жёстко, на размазанном отпускает
4Порядок разный: vLLM — temperature → min_p → top_k → top_p; HuggingFace — temperature → top_k → top_p → min_p. Один конфиг, два движка, распределения могут разойтись
5temperature=0 не даёт детерминизма: дело не только во float, а в отсутствии batch invariance — твой ответ зависит от того, кто попал с тобой в батч

Ручки сэмплинга — это не магические слова из чужого конфига, а четыре конкретные операции над массивом чисел, у каждой из которых есть формула и порядок применения. Скопировать temperature=0.7, top_p=0.95 можно и не задумываясь — работает же. Но как только конфиг перестаёт работать («модель то умная, то несёт чушь», «на другом движке ведёт себя иначе», «temperature=0, а ответы всё равно скачут») — карго-культ заканчивается ровно там, где начинается вопрос «а что здесь вообще происходит с числами». 🫡


Источники

  1. Turning Up the Heat: Min-p Sampling for Creative and Coherent LLM Outputs — Nguyen et al. (arXiv:2407.01082)
  2. llama.cpp PR #3841 — min-p sampling (kalomaze)
  3. Defeating Nondeterminism in LLM Inference — Horace He et al., Thinking Machines
  4. vLLM, исходники: vllm/v1/sample/sampler.py и vllm/v1/sample/ops/topk_topp_sampler.py
  5. HuggingFace transformers, исходники: generation/logits_process.py
  6. Structured Outputs: как получить от LLM валидный JSON
  7. Спекулятивный декодинг: ускоряем LLM в 2–3 раза
  8. Токенизация: почему русский текст дороже английского
  9. KV-кэш: почему LLM помнит без памяти и жрёт VRAM
  10. Позиционное кодирование: RoPE, YaRN и почему Inkling от них отказался