Подробные статьи о технологиях, определяющих будущее.

Constrained Decoding: LLM как быстрая модель решений

Constrained decoding превращает LLM в быстрый классификатор. Разбираем маскирование логитов, калибровку и temperature scaling для надёжных моделей решений.

Луч света разделяется на пять цветных лучей, проходя через ворота, иллюстрируя ограниченный вывод токенов.
Маскирование словаря заставляет всё распределение выхода модели проходить через несколько разрешённых «ворот».

Вот простой трюк, который незаметно очень полезен: если вас интересует только первый токен, который выдала бы LLM, можно задать модели вопрос с вариантами ответа и получить результат за один прямой проход. Constrained decoding — маскирование всего словаря, кроме нескольких допустимых токенов — превращает генеративную модель в нечто очень похожее на классификатор. Никакого парсинга JSON, никаких циклов повторных попыток, никаких одиннадцати авторегрессивных шагов ради одиннадцати символов. Один проход, один softmax, один ответ с вероятностью для каждого варианта.

Эта идея давно циркулирует под названиями вроде «decision models» или «system one» inference, а недавно она взорвала Hacker News благодаря разбору того, как собрать такую модель на основе Qwen с 1,7 млрд параметров примерно за сорок строк Python. Седобородые специалисты по машинному обучению в комментариях, как и ожидалось, писали возмущённые отклики: это классификатор, такие были ещё со времён перцептрона. Они правы, но немного упускают суть. Новизна не в концепции, а в том, что вы получаете zero-shot классификатор из универсальной языковой модели без какого-либо обучения. Интересный инженерный вопрос в другом: когда такой ограниченный подход бьёт просто разговорную генерацию, а когда он незаметно обманывает вас переуверенными вероятностями.

Два способа получить ответ от LLM

Обычный режим общения с языковой моделью — генерация. Вы задаёте вопрос, модель выдаёт токены по одному, а где-то дальше вы парсите то, что получилось. Если нужен структурированный вывод, добавляют схему: JSON mode, грамматически ограниченное сэмплирование, конечные автоматы в стиле outlines. Всё это работает, но модель по-прежнему проходит токен за токеном весь ответ. Простой ответ с выбором варианта может потребовать одиннадцать шагов декодирования, и каждый шаг — полный прямой проход по миллиардам параметров.

Альтернатива — вообще не позволять ей проходить этот путь. После обработки промпта вы смотрите на логиты по словарю в последней позиции, отбрасываете всё, кроме ID токенов, соответствующих вашим вариантам, — скажем, «A», «B», «C», «D», «E» — и применяете softmax только к ним. Argmax — ваше предсказание, значения softmax — оценки. Общая стоимость: один прямой проход, тот же prefill, за который вы и так платили. Вот суть, адаптированная из подхода на основе Qwen, который сейчас разлетелся по сети:

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_name = "Qwen/Qwen3-1.7B"
options = ["A", "B", "C", "D", "E"]
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
model_name, torch_dtype="auto", device_map="auto"
)
# First token the model would emit for each option
option_token_ids = [
tokenizer.encode(opt, add_special_tokens=False)[0] for opt in options
]
prompt = (
"What color is the sky?\n"
"A. Red\nB. Blue\nC. Green\nD. Purple\nE. I don't know\nAnswer:"
)
messages = [{"role": "user", "content": prompt}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True,
enable_thinking=False
)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
with torch.no_grad():
logits = model(**inputs).logits[0, -1]
# Constrained decoding: softmax over only the option tokens
probs = torch.softmax(logits[option_token_ids].float(), dim=-1)
print(options[probs.argmax().item()])  # -> B

Это и есть весь движок. Словарь насчитывает более 150 000 токенов, а мы свели решение к пяти числам. Никаких игр с temperature на этапе генерации, никакого парсера, который может упасть, никакой возможности, что модель выдумает вариант, которого нет в списке. Пространство выходов замкнуто по построению.

Это классификатор, и это нормально

Отдадим скептикам должное. То, что мы построили, — дискриминативный классификатор над фиксированным набором меток, потомок идей, восходящих к перцептрону Розенблатта 1950-х, через логистическую регрессию и через каждую нейросеть с softmax-головой эпохи глубокого обучения. Если прищуриться, constrained decoding — это просто линейное считывание финального скрытого состояния, то есть ровно то, чем всегда была классификационная голова. Специалист по ML, который годами уговаривал команду просто обучить классификатор, имеет полное право немного возмущаться, глядя, как это переименовывают в «модель решений».

Но история также показывает, почему новая версия важна. Старые классификаторы были узкими: вы собирали размеченные данные, обучали модель, и она знала одну задачу и больше ничего. Причина, по которой системы на основе LLM продолжают поглощать задачи, которые «должны» решаться обычным классификатором, — свойство zero-shot: базовая модель уже впитала достаточно знаний о мире, так что промпт и есть обучающие данные. Когда я проверил подобную схему на отложенной выборке CommonsenseQA, модель на 1,7 млрд параметров выдала примерно 59% точности без какого-либо дообучения, а после быстрой настройки на обучающем сплите — около 62%. Это не state of the art, но заняло это один вечер и не потребовало собственных размеченных данных. Специализированный классификатор, возможно, справился бы лучше; но ему понадобились бы пайплайн, датасет и история переобучения при каждом изменении меток.

Здесь уместна рамка из старого спора о генеративных и дискриминативных классификаторах. Генеративные моделируют всё распределение, расточительны, но гибки; дискриминативные моделируют границу решения, эффективны, но жёсткие. Constrained decoding — странный гибрид: генеративная модель, поставленная на дискриминативную службу на этапе инференса. Вы получаете эффективность дискриминативного считывания с широтой генеративного предобучения. Такой комбинации раньше действительно не было, даже если сами компоненты старые.

Где выигрывает constrained decoding

  • Задержка. Один прямой проход вместо N авторегрессивных шагов. На небольших моделях это разница между «достаточно быстро для пути запроса» и «нужна очередь». Практики, которые запускают чистые модели решений в браузере, сообщают об ответах быстрее 200 мс — попробуйте так с генеративным JSON.
  • Структурная корректность. Модель буквально не может выдать ничего за пределами разрешённого набора. Никакого некорректного JSON, никаких «Ответ, вероятно, B, потому что…», никакого слоя guardrails для отлова нарушений формата.
  • Пропускная способность. Поскольку каждый запрос — один проход одинаковой формы, батчинг тривиален и предсказуем. Генерация ответов переменной длины разрушает эффективность батчинга.
  • Оценка для каждого варианта. Вы получаете всё распределение, а не только победителя. Это открывает путь к логике отказа от ответа: если верхняя вероятность ниже порога, передайте запрос человеку или более крупной модели.

На пункте об отказе от ответа стоит остановиться подробнее. Генеративный ответ — это один артефакт, которому вы либо доверяете, либо нет. Распределение вероятностей по вариантам позволяет строить маршрутизацию: случаи с высокой уверенностью проходят автоматически, с низкой — эскалируются. На этом паттерне держится много продакшен-систем триажа: маршрутизация тикетов поддержки, предварительная модерация контента, определение намерений — и именно здесь эта техника окупается. Если ваша задача естественным образом раскладывается на «выбери одну из K меток и скажи, насколько уверен», constrained decoding почти наверняка подходящий инструмент.

Где выигрывает генеративный вывод

А теперь другая сторона. Как только задача перестаёт укладываться в фиксированный набор меток, constrained decoding рассыпается. Если ответ — свободная сущность, число, фрагмент кода или что-то композиционное, нужна генерация, возможно, с ограничениями структурированного вывода, но всё равно генерация. Есть и более тонкая потеря: рассуждения. Когда модель генерирует цепочку рассуждений перед ответом, на сложных вопросах она часто работает заметно лучше. Одиночная голова решения не даёт черновика для размышлений. Вы просите системное мышление «один», быстрое и интуитивное, и получаете именно его, включая характерные для него ошибки.

Есть также проблема чувствительности к формулировке. Ограниченный классификатор над «A/B/C/D/E» на самом деле измеряет предпочтение модели к тексту каждого варианта на данной позиции. Немного переформулируйте вариант C, переставьте список или замените «Answer:» на «The best answer is» — и оценки могут сдвинуться. Генеративные ответы с рассуждениями, как правило, устойчивее к поверхностным возмущениям, потому что модели приходится опираться на содержание, а не только на токен. Если вы оцениваете любой из подходов, возмутите подачу и посмотрите, что сломается: это дешёвый тест на устойчивость и неприятный одновременно.

Две тропы через ландшафт, одна прямая, другая извилистая, символизирующие быстрый и вдумчивый инференс.
Constrained decoding — прямой путь; генерация с рассуждениями — длинная тропа, которая иногда приводит на более высокое место.

Проблема калибровки: ваши оценки уверенности врут

Вот ловушка, в которую попадает каждый, кто строит такую систему. Вероятности получены из softmax, значит, это вероятности, верно? Нет. Это уверенность модели в том, что данный токен пойдёт следующим, — утверждение о языке, а не о правильности. Спросите модель «Где вы скорее всего найдёте летучую мышь?» с вариантами «Пещера» и «Бейсбольный матч», и она присвоит «Пещере» что-то вроде 0,998 — неоднозначный вопрос без обоснованно определённого ответа, а модель отвечает с почти полной уверенностью.

Если разбить предсказания по уровням уверенности на реальной оценке, картина становится хуже. В одном прогоне на CommonsenseQA корзина 0,9–1,0 оказалась верной лишь примерно в 70% случаев, а корзина 0,8–0,9 едва достигла 40%. Хорошо откалиброванная модель должна быть права примерно в 90% случаев, когда говорит 0,9. Эта модель систематически переуверена — что, если задуматься, перекликается со старым наблюдением, что современные глубокие сети в целом переуверены. Guo и соавторы показали ещё в 2017 году, что softmax-выходы обычного ResNet плохо откалиброваны по сравнению с неглубокими сетями 1990-х. Всё старое становится новым; мы просто переизобрели проблему на масштабе 1,7 млрд параметров.

Хорошая новость: решение тоже старое и почти неприлично простое — temperature scaling. Разделите логиты на один обученный скаляр T перед softmax:

def scaled_probs(logits, option_ids, temperature):
selected = logits[option_ids].float() / temperature
return torch.softmax(selected, dim=-1)
# Fit T on a validation set by minimizing negative log-likelihood
# of the correct option. For the CommonsenseQA run above, T ~= 3.8
temperature = 3.7973
probs = scaled_probs(logits, option_token_ids, temperature)

T больше 1 сглаживает распределение; T меньше 1 делает его резче. Подбор одного числа по отложенной выборке превратил жалкую таблицу калибровки во что-то честное: корзина 0,9–1,0 теперь соответствует примерно 95% точности, корзина 0,5–0,6 — примерно 55%. Точность вообще не меняется, ведь argmax инвариантен к монотонному масштабированию, но оценки теперь означают то, что вы под ними подразумевали. Если собираетесь маршрутизировать по этим вероятностям, сначала калибруйте, иначе не стоит их и собирать.

Подбирать temperature по бенчмарку — это читерство?

Справедливый вопрос из обсуждения: разве подгонка T, чтобы модель выглядела откалиброванной на бенчмарке, — не немного p-hacking? Было бы, если подгонять на тестовой выборке. Сделано правильно — подбираем T на валидационном сплите, а калибровку отчитываем на отложенном тестовом — это просто однопараметрическая регрессия, и именно поэтому это стандартная практика в литературе. Важна та же дисциплина, что и везде в ML: честные сплиты и недоверие к любому утверждению о калибровке, измеренному на данных, которые участвовали в подгонке. Если домен развёртывания уходит от домена оценки, ваш T тоже уходит, поэтому перекалибровка должна входить в цикл поддержки наравне со всем остальным.

По сути это частный случай более широкой болезни: разрыва между тем, что система задумана означать, и тем, что она на самом деле вычисляет. Я уже писал, что именно в этом разрыве живут баги. «Уверенность» задумана как вероятность правильности; реализация даёт правдоподобие следующего токена. Temperature scaling — это заплатка над разрывом, а не его устранение. Держите это различие в голове каждый раз, когда возникает соблазн подключить выходы softmax к порогу алертов.

Практическая процедура принятия решений

Когда я сейчас выбираю между двумя режимами, прохожусь по короткому чек-листу:

  1. Выход — фиксированный набор меток? Если да, constrained decoding на столе. Если нет, генерируем.
  2. Нужны ли рассуждения для приемлемой точности? Прототипируйте оба варианта. Если одиночная голова заметно отстаёт от chain-of-thought сильнее, чем вы готовы терпеть, побеждает генерация, несмотря на задержку.
  3. Нужны ли оценки по каждому варианту для маршрутизации или отказа? Если да, constrained decoding плюс temperature scaling практически бесплатны, а генерация не даёт ничего сопоставимого.
  4. Насколько велик набор меток? Пять вариантов — тривиально; пять тысяч — территория поиска. При больших пространствах меток эмбеддите метки, сужайте список векторным поиском и только потом используйте модель, чтобы выбрать из финалистов — трюк масштабирования классификаторов, который стандартен со времён рекомендательных систем.
  5. Будут ли метки часто меняться? Свойство zero-shot — и есть главное преимущество. Если метки меняются каждую неделю, переобучаемый классификатор становится грузом на поддержке; правка промпта — нет.

Softmax модели — это утверждение о том, какой токен пойдёт следующим, а не о том, что истинно. Калибруйте его или не доверяйте.

Ещё одно соображение: размер модели влияет на выбор. Однопроходная голова маленькой модели достаточно дешева, чтобы запускать её на каждом запросе, даже на клиенте; люди уже выкатывают модели решений, которые работают в браузере с ответами быстрее 200 мс. Каскадная схема — маленькая ограниченная модель для лёгких 80% и большая генеративная для трудного хвоста — часто выигрывает у любого из крайних вариантов и по стоимости, и по точности. Это тот же инстинкт, что стоит за speculative decoding, только применённый на уровне системы, а не токена.

Рекомендация

Моя позиция: если ваша задача — действительно фиксированный выбор, например маршрутизация, триаж, определение намерений или оценка multiple-choice, стройте constrained decision head и не оглядывайтесь. Одной задержки достаточно, структурные гарантии устраняют целый класс ошибок парсинга, а оценки по вариантам дают логику маршрутизации, которой генерация не может соответствовать. Но относитесь к сырому softmax как к некалиброванному прибору. Дообучайте на своей задаче, если можете собрать хотя бы скромный датасет, подбирайте temperature на чистой валидационной выборке и проверяйте калибровку на данных, которых подгонка не видела.

Генеративный вывод — с ограничениями структурированного вывода там, где они нужны, — оставьте для задач, которые действительно композиционные или выигрывают от видимых рассуждений. И не позволяйте никому продавать вам «модель решений» как новую категорию: это классификатор в пальто LLM, наследник шестидесяти лет дискриминативного моделирования, и он наиболее полезен именно тогда, когда вы уважаете эту родословную настолько, чтобы сделать работу по калибровке, на которой всегда настаивали старожилы. Инструменты новые. Дисциплина — нет.