Генерация текста и сэмплирование
Как превратить распределение следующего токена в текст — greedy, температура, top-k, top-p, штрафы за повторение — и почему даже «температура 0» не гарантирует одинаковых ответов.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Языковая модель выдаёт не текст, а распределение следующего токена. Как из него выбрать токен, решает стратегия декодированиястратегия декодированияEN decoding strategyПравило, по которому из распределения следующего токена выбирается сам токен (greedy, beam search, сэмплирование с температурой, top-k, top-p)., и от неё зависит поведение модели не меньше, чем от весов. Одна и та же модель при одних настройках зацикливается на «кот смотрит на окне . кот смотрит на окне . …», при других несёт чепуху, а при третьих пишет связный и живой текст.
Эти настройки вы будете встречать постоянно: temperature и top_p есть почти в каждом API (модуль 8.1), top_k —
во многих, а также в vLLM и Ollama (ступень 7). В model card моделей Qwen и DeepSeek есть раздел с рекомендуемыми
значениями.
Сегодня разберём, что они делают с распределением, почему модели зацикливаются и почему «температура 0» не означает
«каждый раз одно и то же».
Конвейер декодирования
Заголовок раздела «Конвейер декодирования»На каждом шаге генерации логиты проходят цепочку преобразований и только потом превращаются в токен:
flowchart LR A["Логиты z<br/>(V чисел)"] --> B["Штрафы<br/>за повторы"] B --> C["÷ температура"] C --> D["top-k"] D --> E["top-p"] E --> F["softmax"] F --> G["Случайный<br/>выбор токена"] G --> H["Дописать<br/>в контекст"]
Фильтры не трогают саму модель: они только переписывают логиты. «Выбросить токен» значит поставить его логиту : после softmax его вероятность станет ровно 0, а остальные перенормируются.
Greedy: всегда самый вероятный
Заголовок раздела «Greedy: всегда самый вероятный»Самая простая стратегия — жадное декодированиежадное декодирование (greedy)EN greedy decodingНа каждом шаге берётся самый вероятный токен (argmax). Детерминировано, но склонно к повторам.: на каждом шаге берём . Она детерминирована и кажется разумной: зачем выбирать менее вероятный токен?
Беда в том, что самый вероятный текст не похож на человеческий. Holtzman и соавторы в работе о nucleus sampling (2019) показали, что люди не выбирают каждое следующее слово максимально предсказуемо: в живом тексте то и дело встречаются слова с невысокой вероятностью. А максимизация вероятности даёт скучный текст, который часто зацикливается. Повтор усиливает сам себя: чем больше раз фраза уже встретилась в контексте, тем вероятнее модель её повторит.
Проще всего увидеть это на крошечной модели. Возьмём словесную биграммную модель (урок 2) на нескольких предложениях про кота. У биграммной модели greedy — детерминированная функция последнего слова, поэтому как только слово повторилось, цикл неизбежен:
greedy: кот смотрит на окне . кот смотрит на окне . кот смотрит на окне .Код этой модели — в разделе «Практика». У больших LLM контекст длинный, и всё не так прямолинейно, но эффект тот же: длинные повторяющиеся куски — классический симптом greedy на открытой генерации.
Температура: острее или площе
Заголовок раздела «Температура: острее или площе»С температуройтемпература (сэмплирования)EN temperatureЛогиты делятся на T перед softmax. T < 1 — распределение острее, T > 1 — площе, T → 0 — greedy. вы уже встречались в модуле 1.3, когда считали энтропию. Напомним: температура делит логиты перед softmax:
- — распределение модели как есть.
- — разрыв между логитами растёт, распределение острее, модель «увереннее». При вся вероятность уходит самому вероятному токену — это greedy.
- — распределение площе, у маловероятных токенов появляются шансы. При — равномерное.
Температура не меняет порядок токенов, только форму распределения. Пример: логиты . При вероятности , при логиты становятся и вероятности — .
Посмотрим на два контекста: неопределённый («Мама мыла …» — продолжений много) и уверенный («Столица Китая — …»).
import torchimport torch.nn.functional as F
def top_k_filter(logits, k): kth = torch.topk(logits, k).values[-1] # k-е по величине значение # masked_fill(маска, x): там, где маска True, подставить x; остальное оставить как было return logits.masked_fill(logits < kth, float("-inf"))
def top_p_filter(logits, p): probs = F.softmax(logits, dim=-1) # сортируем по убыванию; idx[k] — исходный номер токена, оказавшегося на k-м месте sorted_probs, idx = torch.sort(probs, descending=True) cum = torch.cumsum(sorted_probs, dim=-1) # накопленные суммы: [0.46, 0.66, 0.80, …] drop_sorted = cum - sorted_probs >= p # масса ДО токена уже ≥ p → токен лишний # маска посчитана в отсортированном порядке; scatter раскладывает её обратно: # drop[idx[k]] = drop_sorted[k], то есть «отметку» k-го по рангу токена ставим на его исходное место drop = torch.zeros_like(drop_sorted).scatter(0, idx, drop_sorted) return logits.masked_fill(drop, float("-inf"))
def show(title, tokens, logits): """Печатает таблицу: вероятности токенов при разных температурах и фильтрах.""" print(title) print(" " + " ".join(f"{t[:6]:>6}" for t in tokens)) rows = [("T = 0.5", F.softmax(logits / 0.5, -1)), ("T = 1", F.softmax(logits, -1)), ("T = 2", F.softmax(logits / 2, -1)), ("top-k=3", F.softmax(top_k_filter(logits, 3), -1)), ("top-p=.9", F.softmax(top_p_filter(logits, 0.9), -1))] for name, pr in rows: print(f" " + " ".join(f"{x:6.2f}" for x in pr.tolist()) + f" ← {name}")
show("«Мама мыла …»", ["раму", "посуду", "окно", "пол", "кота", "машину", "руки", "Пекин"], torch.tensor([3.0, 2.2, 1.8, 1.5, 0.5, 0.3, 0.2, -2.0]))show("«Столица Китая — …»", ["Пекин", "город", "Шанхай", "один", "Нанкин", "это", "Москва", "кот"], torch.tensor([6.0, 2.5, 1.5, 1.0, 0.8, 0.5, 0.0, -1.0]))«Мама мыла …» раму посуду окно пол кота машину руки Пекин 0.74 0.15 0.07 0.04 0.00 0.00 0.00 0.00 ← T = 0.5 0.46 0.21 0.14 0.10 0.04 0.03 0.03 0.00 ← T = 1 0.28 0.19 0.15 0.13 0.08 0.07 0.07 0.02 ← T = 2 0.57 0.26 0.17 0.00 0.00 0.00 0.00 0.00 ← top-k=3 0.51 0.23 0.15 0.11 0.00 0.00 0.00 0.00 ← top-p=.9«Столица Китая — …» Пекин город Шанхай один Нанкин это Москва кот 1.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 ← T = 0.5 0.94 0.03 0.01 0.01 0.01 0.00 0.00 0.00 ← T = 1 0.63 0.11 0.07 0.05 0.05 0.04 0.03 0.02 ← T = 2 0.96 0.03 0.01 0.00 0.00 0.00 0.00 0.00 ← top-k=3 1.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 ← top-p=.9Сравните строки : в первом контексте высокая температура даёт разнообразие, во втором — 3% на «Москву» и 2% на «кота» в ответе про столицу Китая. Отсюда главная проблема сэмплирования: у распределения есть длинный хвост из тысяч маловероятных токенов. По отдельности каждый почти невозможен, но вместе их масса заметна. Если на каждом шаге хвост даёт хотя бы 5%, то за 100 шагов модель почти наверняка вытянет оттуда что-нибудь странное, а дальше авторегрессия разовьёт ошибку. Хвост надо отрезать.
Top-k: только k лучших
Заголовок раздела «Top-k: только k лучших»top-k сэмплированиеtop-k сэмплированиеEN top-k samplingСэмплирование только из k самых вероятных токенов (остальные получают вероятность 0). оставляет самых вероятных токенов, остальным ставит и перенормирует. Идея появилась в работе Fan et al. (2018) о генерации историй. В таблице при «Мама мыла …» сэмплирует только из «раму / посуду / окно».
Слабое место — фиксированное . В неопределённом контексте хороших продолжений может быть сотня, и режет живые варианты («пол» тоже хорош). В уверенном контексте хорошее продолжение одно, а всё равно впускает «Шанхай» с 1%. Подходящее зависит от контекста, а мы задаём его один раз на всю генерацию.
Top-p (nucleus): минимальное ядро вероятности
Заголовок раздела «Top-p (nucleus): минимальное ядро вероятности»top-p (ядерное) сэмплированиеtop-p (ядерное) сэмплированиеEN top-p (nucleus) samplingСэмплирование из минимального набора самых вероятных токенов с суммарной вероятностью ≥ p. Holtzman et al., 2019. (Holtzman et al., 2019) делает число кандидатов адаптивным:
- Отсортировать токены по убыванию вероятности.
- Идти сверху и накапливать вероятность.
- Остановиться, как только сумма достигла . Взятые токены — «ядро» (nucleus), остальные выбросить.
- Перенормировать и сэмплировать из ядра.
Для «Мама мыла …» накопленные суммы: . При в ядро входят 4 токена. Для «Столица Китая — …» уже первый токен даёт 0.94, и ядро состоит из одного «Пекина». Одно и то же даёт 4 кандидата там, где модель сомневается, и 1 — там, где она уверена.
Тонкость реализации: токен попадает в ядро, если масса до него ещё меньше . Поэтому самый вероятный токен
остаётся всегда, даже при крошечном , и ядро никогда не бывает пустым. В коде выше это строка
cum - sorted_probs >= p. Top-p с температурой на NumPy — частая задача на собеседовании; её ловушки
и дополнительные вопросы интервьюера разобраны в уроке 11.3.4.
Фильтры комбинируют: сначала температура, потом top-k, потом top-p. Типичные значения: от 0.6 до 1.0, от 0.8 до 0.95, от 20 до 50.
Поиграйте сами
Заголовок раздела «Поиграйте сами»Подвигайте ползунки: при температуре 0.2 почти вся вероятность уходит одному токену, при 2.0 распределение заметно площе: доля лидера падает с 71% до 44%, и у хвоста появляются шансы. Включите top-k = 2 и посмотрите, как «хвост» исчезает целиком. Потом сравните с top-p = 0.9: число оставшихся токенов зависит от формы распределения. Кнопка «Сэмплировать» показывает, что получится на практике.
Повторы и штрафы за них
Заголовок раздела «Повторы и штрафы за них»Даже с сэмплированием модели зацикливаются, особенно маленькие и особенно при низкой температуре. Против этого есть три распространённых штрафа:
| Штраф | Как меняет логит уже встречавшегося токена | Где встречается |
|---|---|---|
| штраф за повторениештраф за повторениеEN repetition penaltyПонижение логитов токенов, которые уже встречались в тексте (CTRL, presence/frequency penalty в API). | , если ; , если | Hugging Face, vLLM (идея — из CTRL, 2019) |
| presence penalty | , если токен встречался хотя бы раз | OpenAI-совместимые API, vLLM |
| frequency penalty | , где — сколько раз встречался | OpenAI-совместимые API, vLLM |
Repetition penalty — мультипликативный. Его предложили авторы CTRL: логит уже встречавшегося токена делят на , а в статье рекомендуют . Но у простого деления есть изъян. Деление положительного числа на уменьшает его, а отрицательного — увеличивает (), и штраф превращается в поощрение. Поэтому в Hugging Face (и так же в vLLM) отрицательные логиты умножают на : так штраф всегда понижает логит. Учтите ещё одну деталь: в Hugging Face штраф по умолчанию действует и на токены промпта, а не только на сгенерированные.
Штрафы — грубый инструмент. Модель не отличает вредный повтор от нужного: в коде одна и та же переменная встречается десятки раз, в математике повторяются символы, в пересказе — имена героев. Сильный штраф заставит модель придумывать синонимы там, где они не нужны. Поэтому штрафы держат маленькими или выключают и включают, только когда модель действительно зацикливается.
Почему «температура 0» не всегда детерминирована
Заголовок раздела «Почему «температура 0» не всегда детерминирована»Многие уверены: поставь temperature=0, и ответ всегда будет одинаковым. API обычно трактуют это как greedy,
а — детерминированная функция логитов. Но логиты на сервере сами по себе не детерминированы.
В сентябре 2025 года Хорас Хе и коллеги из Thinking Machines Lab описали в блоге компании показательный эксперимент. Модель Qwen3-235B-A22B-Instruct-2507 1000 раз ответила на запрос «Tell me about Richard Feynman» при температуре 0. Получилось 80 разных ответов, самый частый встретился лишь 78 раз. Первые 102 токена совпадали у всех, а на 103-м пути разошлись: после «Feynman was born on May 11, 1918, in» 992 ответа продолжили «Queens, New York», а 8 — «New York City».
Откуда разница? Цепочка такая:
- Сложение floatчисло с плавающей точкой (float)EN floating-point numberСпособ хранить дробные числа как «мантисса × 2 в степени». Форматы: FP32 (4 байта), FP16 и BF16 (2 байта), FP8 (1 байт). не ассоциативно: может не равняться из-за округления.
- GPU-ядра (kernels — маленькие программы, которые видеокарта выполняет параллельно на тысячах потоков) суммируют блоками, и разбиение на блоки зависит от размера батчаразмер батчаEN batch sizeЧисло примеров (у LLM — часто число токенов) на один шаг оптимизатора. Больше батч — меньше шум градиента, но дороже шаг.: от того, сколько чужих запросов сервер обрабатывает вместе с вашим. Нагрузка меняется каждую секунду.
- Логиты получаются чуть разными. Обычно это ни на что не влияет, но если два кандидата почти равны (как «Queens» и «New» выше), переключается.
- Дальше работает авторегрессия: один другой токен — и весь последующий текст другой.
Авторы показали, что главный виновник — именно отсутствие инвариантности к батчу в ядрах нормализации, матричного умножения и внимания. Когда они заменили ядра на батч-инвариантные, все 1000 ответов совпали.
Механизм легко воспроизвести на CPU. Посчитаем один и тот же логит — скалярное произведение двух векторов длины 4096 — с разным числом блоков суммирования, как это делают ядра при разных размерах батча:
import numpy as np
print((0.1 + 0.2) + 0.3 == 0.1 + (0.2 + 0.3)) # сложение float не ассоциативно
rng = np.random.default_rng(0)h = rng.standard_normal(4096).astype(np.float32) # скрытое состояние последнего токенаw = rng.standard_normal(4096).astype(np.float32) # строка LM-головы для токена A (LM-голова — последний # слой модели: вектор → логиты всех токенов; модуль 4.5)prod = h * w # логит = сумма этих 4096 произведений
def chunked_sum(v, n_chunks): """Так суммирует GPU-ядро: частичные суммы по блокам, затем сумма сумм.""" # np.array_split режет вектор на n_chunks почти равных кусков parts = [np.sum(c, dtype=np.float32) for c in np.array_split(v, n_chunks)] return np.float32(sum(parts, np.float32(0)))
logit_b = np.float32(-18.137735) # логит конкурента — токена Bfor n in [1, 4, 8, 16, 64]: a = chunked_sum(prod, n) print(f"блоков {n:2}: логит A = {a:.6f} → greedy выбирает {'A' if a > logit_b else 'B'}")Falseблоков 1: логит A = -18.137739 → greedy выбирает Bблоков 4: логит A = -18.137739 → greedy выбирает Bблоков 8: логит A = -18.137741 → greedy выбирает Bблоков 16: логит A = -18.137733 → greedy выбирает Aблоков 64: логит A = -18.137737 → greedy выбирает BМатематически это одно и то же число, а в float32 получилось четыре разных значения, и при разбиении на 16 блоков greedy выбирает другой токен. Здесь почти-ничья подобрана специально, но в словаре из 150 тысяч токенов на тысячах шагов генерации такие ситуации неизбежны. В BF16BF16 (bfloat16)EN bfloat16 (brain floating point)16-битный формат: 1 бит знака, 8 бит порядка (как у FP32), 7 бит мантиссы. Диапазон как у FP32, точность — 2–3 значащие цифры. Стандарт обучения LLM., где у числа всего 7 бит мантиссы (значащих цифр, урок 0.0.2) против 23 у FP32, расхождения гораздо больше. Подробно о форматах BF16 и FP8 — в уроке 5.4.1.
Практика
Заголовок раздела «Практика»Браузерное задание: top-k и top-p
Заголовок раздела «Браузерное задание: top-k и top-p»Реализуйте на чистом Python softmax с температурой, фильтры top_k_filter и top_p_filter и функцию sample,
которая собирает их в один шаг декодирования. Тесты проверят точные значения, крайние случаи (крошечное ,
) и статистику сэмплов.
import math
import random
NEG_INF = float("-inf")
def softmax(logits, temperature=1.0):
"""Устойчивый softmax с температурой: p_i ∝ exp(z_i / T).
Логит −inf должен давать вероятность ровно 0. Температура T > 0.
"""
# TODO: ваш код
...
def top_k_filter(logits, k):
"""Оставляет k самых больших логитов, остальные заменяет на −inf.
Возвращает НОВЫЙ список той же длины (исходный не меняйте).
Если k >= len(logits), возвращает копию без изменений.
"""
# TODO: ваш код
...
def top_p_filter(logits, p):
"""Nucleus (top-p): оставляет минимальный набор самых вероятных токенов,
суммарная вероятность которого >= p. Остальным логитам — −inf.
Вероятности считаются через softmax(logits). Хотя бы один токен остаётся всегда.
Порядок элементов в возвращаемом списке — как во входном.
"""
# TODO: ваш код
...
def sample(logits, rng, temperature=1.0, top_k=None, top_p=None):
"""Один шаг декодирования. Возвращает индекс выбранного токена.
temperature == 0 → greedy (индекс максимального логита).
Иначе: логиты / T → top-k (если задан) → top-p (если задан) → softmax → случайный выбор.
Для случайности используйте только rng (random.Random), например rng.choices(...).
"""
# TODO: ваш код
...
# Кнопка «Запустить»: «Мама мыла …» — раму, посуду, окно, пол, кота, машину, руки, Пекин
logits = [3.0, 2.2, 1.8, 1.5, 0.5, 0.3, 0.2, -2.0]
print("softmax, T=1: ", softmax(logits))
print("softmax, T=0.5:", softmax(logits, 0.5))
print("top-k=3: ", top_k_filter(logits, 3))
print("top-p=0.9: ", top_p_filter(logits, 0.9))
Локально: генерация со всеми настройками
Заголовок раздела «Локально: генерация со всеми настройками»Полный цикл генерации со штрафом за повторение, температурой, top-k и top-p на словесной биграммной модели. Логиты здесь — со сглаживанием 0.01, как в уроке 2.
import torchimport torch.nn.functional as F
text = ("кот сидит на окне . кот смотрит на улицу . на улице идёт снег . " "снег идёт и идёт . кот смотрит на снег и думает о рыбе . " "рыба живёт в реке . кот любит рыбу и спит на окне .")words = text.split()vocab = sorted(set(words)) # словарь — все разные словаstoi = {w: i for i, w in enumerate(vocab)} # слово → номерV = len(vocab)N = torch.zeros(V, V) # счётчики биграмм слов, как в уроке 2for a, b in zip(words, words[1:]): N[stoi[a], stoi[b]] += 1# log(N + 0.01) — сглаженные «log-счётчики»; log_softmax нормирует строку и сразу берёт логарифм,# так что logits_table[i, j] = log P(слово j | слово i)logits_table = torch.log_softmax(torch.log(N + 0.01), dim=-1)
def generate(start, steps, temperature=1.0, top_k=None, top_p=None, repetition_penalty=1.0, seed=1): g = torch.Generator().manual_seed(seed) out = [stoi[start]] # номера сгенерированных слов for _ in range(steps): logits = logits_table[out[-1]].clone() # логиты по последнему слову; clone — копия, чтобы не испортить таблицу # 1) штраф за повторение (как в Hugging Face): уже встречавшиеся токены — менее вероятны if repetition_penalty != 1.0: seen = torch.tensor(sorted(set(out))) # номера уже встречавшихся слов s = logits[seen] # torch.where(условие, a, b): поэлементно берёт a там, где условие True, иначе b logits[seen] = torch.where(s > 0, s / repetition_penalty, s * repetition_penalty) if temperature == 0: # 2) greedy nxt = int(torch.argmax(logits)) else: logits = logits / temperature # 3) температура if top_k is not None: # 4) top-k kth = torch.topk(logits, top_k).values[-1] logits[logits < kth] = float("-inf") if top_p is not None: # 5) top-p probs = F.softmax(logits, -1) sp, idx = torch.sort(probs, descending=True) drop = (torch.cumsum(sp, -1) - sp) >= top_p logits[idx[drop]] = float("-inf") # idx[drop] — исходные номера выброшенных слов nxt = int(torch.multinomial(F.softmax(logits, -1), 1, generator=g)) # 6) сэмплирование out.append(nxt) return " ".join(vocab[i] for i in out)
print("greedy: ", generate("кот", 14, temperature=0))print("greedy+штраф:", generate("кот", 14, temperature=0, repetition_penalty=2.0))print("T=1.0: ", generate("кот", 14, temperature=1.0))print("T=0.7, p=0.9:", generate("кот", 14, temperature=0.7, top_p=0.9))greedy: кот смотрит на окне . кот смотрит на окне . кот смотрит на окне .greedy+штраф: кот смотрит на окне . кот любит рыбу и думает о рыбе . кот сидитT=1.0: кот сидит на снег . рыба живёт в реке в . кот любит улице идётT=0.7, p=0.9: кот сидит на снег . рыба живёт в реке . рыба живёт в реке .Сэмплы зависят от версии PyTorch; greedy-строки — нет. Что видно:
- greedy зацикливается через 5 слов.
- Штраф разрывает цикл: «окне» и «смотрит» уже были, и модель уходит в «любит рыбу». Но «кот» и «.»
повторяются всё равно — без них текст невозможен. Заметьте: все наши логиты отрицательны (это логарифмы
вероятностей), поэтому работает ветка с умножением. Замените
torch.where(...)на простое делениеs / repetition_penalty, как в исходном CTRL, и greedy+штраф снова выдаст «кот смотрит на окне . кот смотрит на окне …»: деление поднимает отрицательные логиты уже встречавшихся слов. - T = 1 без фильтров вытаскивает из сглаженного хвоста бессмыслицу: «в реке в», «любит улице».
- T = 0.7 и top-p = 0.9 отрезают хвост: склеек вроде «в реке в» больше нет. Но снова появляется повтор, а «сидит на снег» напоминает, что биграммная модель видит только одно предыдущее слово («на снег» встречалось в «смотрит на снег»). Низкая температура и усечение хвоста уменьшают разнообразие — это та же цена, что у greedy, только меньше.
Поэкспериментируйте: найдите настройки, при которых за 30 шагов нет ни бессмыслицы, ни повторов. Попробуйте
top_k=2 и сравните с top_p=0.9.
Проверь себя
Заголовок раздела «Проверь себя»- Стратегия декодирования переписывает логиты: штрафы → температура → top-k → top-p → softmax → сэмплирование.
- Greedy детерминирован, но даёт скучный текст и зацикливается. Температура меняет форму распределения, не меняя порядка. Top-k режет хвост с фиксированным числом кандидатов, top-p — адаптивно.
- Штрафы за повторение (мультипликативный repetition penalty по мотивам CTRL, presence, frequency) лечат зацикливание, но мешают нужным повторам — в коде, формулах, именах.
- Temperature 0 ≠ детерминизм на сервере: от размера батча зависит порядок сложений float, почти-ничьи переключаются, авторегрессия раздувает разницу.
- Читайте model card: Qwen3 — , top-p 0.95, top-k 20 для thinking-режима; DeepSeek-R1 — .
Дальше: даём модели контекст длиннее одной буквы — эмбеддинги, скрытый слой и ручной backprop в модели Бенджио на тех же русских именах — Нейросетевая языковая модель: MLP по Бенджио (2003).