
15 сентября 2026 года TypeSafe AI выпустила Jev — модель класса «System One»: она получает состояние и типизированные вопросы, а возвращает варианты, оценки и вероятности без единого слова текста. Сразу после выхода Jev аналоги побежали делать все кому не лень: Liquid AI, например, показала d1 уже через две недели. А мы чем хуже? Ну, по качеству, скорее всего, хуже. Зато бесплатно.
Сейчас мы разберём, что происходит внутри языковой модели, когда она «отвечает» одной буквой, и соберём из этого простой аналог Jev на T4 в Colab: ядро примерно на 70 строк, калибровку вероятностей и замер скорости. Сразу оговорюсь: это не Jev. Мы возьмём обычную instruct-модель и прочитаем решение из её логитов без дообучения, поэтому по качеству до моделей, обученных именно на решения, она не дотянет.
❯ Что мы повторяем
У Jev интерфейс простой: на вход состояние (текст) и набор именованных вопросов, на выходе вероятности. Вопросы бывают трёх типов: да/нет, выбор из вариантов и оценка по шкале. Мы сделаем то же самое: функции Bool, Choice и Score, а на выходе будет словарь с вероятностями.
❯ Как LLM «отвечает»: от токенов до логитов
Чтобы понять, откуда берётся решение без единого слова текста, придётся заглянуть внутрь языковой модели. Ничего страшного там нет: нам хватит шести шагов.
Что происходит за один проход
-
Токенизация. Текст режется на токены (куски слов), и каждому соответствует номер в словаре. У современных моделей в словаре от нескольких десятков тысяч до сотен тысяч токенов.
-
Эмбеддинги. Каждый номер превращается в вектор, с которым умеют работать слои сети.
-
Слои трансформера. Механизм внимания (attention) позволяет каждому токену «заглянуть» в предыдущие и подмешать нужную информацию. После всех слоёв у каждого токена есть своё скрытое состояние: вектор, в котором уже учтён весь контекст слева.
-
Последний токен. Чтобы предсказать следующий токен, нужно только скрытое состояние последнего токена промпта: всё остальное уже вошло в него через внимание.
-
Логиты. Это состояние умножается на матрицу «словарь × размерность», и получается по одному числу на каждый токен словаря. Эти числа называются логитами: чем больше логит, тем охотнее модель поставила бы этот токен следующим.
-
Softmax. Логиты превращаются в вероятности по формуле p_i = exp(z_i) / Σ exp(z_j): все значения положительны и в сумме дают единицу.
Обычная генерация делает с этим распределением одну вещь: выбирает токен (самый вероятный или случайный), дописывает его к тексту и запускает всё заново для следующего токена. Чтобы получить JSON вида {«angry»: true, «team»: «billing», «urgency»: 3}, это нужно повторить десятки раз: скобки, кавычки, ключи и значения тоже токены. Точное число зависит от токенизатора, но порядка двух-трёх десятков шагов, и каждый ждёт предыдущего.
Где прячется решение
Теперь самое интересное. Если в промпте написано «ответь одной буквой: A, B или C», то на первом же шаге модель и так раздаёт вероятности между токенами A, B и C. Ничего выбирать и дописывать не нужно: достаточно посмотреть на эти три числа. Мы выкидываем из словаря всё остальное и пересчитываем softmax только на буквах вариантов: p_A = exp(z_A) / (exp(z_A) + exp(z_B) + exp(z_C)).
Выходит, что решение лежит уже в логитах последней позиции промпта. Сам промпт обрабатывается за один параллельный проход (GPU считает все его токены сразу одной большой матричной операцией), и после него не нужно ждать ни одного сгенерированного токена. Именно поэтому такой подход должен быть быстрее обычной генерации. Ниже мы это измерим.
Почему буквы, а не слова? Слово вроде billing может разбиться на несколько токенов, и тогда его вероятность придётся склеивать из нескольких шагов. Одна буква — один токен, и все варианты честно сравниваются между собой. Платить приходится позиционным смещением: модель может любить варианты на определённых местах списка. С этим мы тоже разберёмся.
Что значит «не галлюцинирует»
Ответ не выйдет за пределы списка вариантов: мы смотрим только на буквы из списка. Но это гарантия формата, а не истины: модель может быть уверенно неправа. Дальше мы проверим, насколько вероятностям вообще можно доверять.
❯ Железо и модель
Бесплатный Colab обычно выдаёт T4 с 16 ГБ памяти (на практике доступно около 15 ГБ), сессия живёт до 12 часов, а наличие GPU никто не гарантирует: лотерея, зато бесплатная. У T4 нет аппаратной поддержки bfloat16, поэтому работаем в float16.
Берём Qwen/Qwen2.5-1.5B-Instruct: в fp16 это около 3 ГБ, и такая модель обычно нормально работает в float16. Если хочется точнее, подойдёт Qwen/Qwen2.5-3B-Instruct (около 6 ГБ). Модели, обученные в bf16, иногда выдают в fp16 NaN, поэтому в коде есть проверка логитов на конечность.
Установка: !pip install -q -U transformers accelerate scikit-learn datasets, затем Runtime → Change runtime type → T4 GPU.
❯ Шаг 1. Ядро
import torchfrom transformers import AutoTokenizer, AutoModelForCausalLMLETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'SYSTEM = ('You are a decision engine. Read the state, then answer the question ' 'with exactly one option letter and nothing else.')def Bool(text): return {'type': 'bool', 'instructions': text, 'options': ['Yes', 'No']}def Choice(text, options): return {'type': 'choice', 'instructions': text, 'options': list(options)}def Score(text, lo=1, hi=5): vals = list(range(lo, hi + 1)) return {'type': 'score', 'instructions': text, 'options': [str(v) for v in vals], 'values': vals}class MiniJev: def __init__(self, model_id, dtype=torch.float16, device='cuda'): self.tok = AutoTokenizer.from_pretrained(model_id, padding_side='left') if self.tok.pad_token is None: self.tok.pad_token = self.tok.eos_token self.model = AutoModelForCausalLM.from_pretrained(model_id, dtype=dtype).to(device).eval() self.device, self._ids = device, {} def _label_ids(self, label): if label not in self._ids: ids = set() for v in (label, ' ' + label): # 'A' и ' A', только если это один токен t = self.tok.encode(v, add_special_tokens=False) if len(t) == 1: ids.add(t[0]) self._ids[label] = sorted(ids) return self._ids[label] def _prompt(self, state, instructions, options): lines = '\n'.join(f'{LETTERS[i]}. {o}' for i, o in enumerate(options)) user = (f'State:\n{state}\n\nQuestion: {instructions}\n\n' f'Options:\n{lines}\n\nAnswer with the letter only.') msgs = [{'role': 'system', 'content': SYSTEM}, {'role': 'user', 'content': user}] return self.tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True) @torch.no_grad() def _option_logits(self, prompts, k): enc = self.tok(prompts, return_tensors='pt', padding=True, add_special_tokens=False).to(self.device) logits = self.model(**enc).logits[:, -1, :].float() # readout at the last position assert torch.isfinite(logits).all(), 'NaN/inf: try float32 or another model' return torch.stack([torch.stack([torch.logsumexp(logits[i, self._label_ids(LETTERS[j])], 0) for j in range(k)]) for i in range(len(prompts))]) def logprobs(self, state, q, debias=True): opts, k = q['options'], len(q['options']) shifts = range(k) if debias else [0] prompts = [self._prompt(state, q['instructions'], opts[r:] + opts[:r]) for r in shifts] lp = torch.log_softmax(self._option_logits(prompts, k), -1) # (shifts, k) out = torch.zeros(k, device=lp.device) for row, r in zip(lp, shifts): for j in range(k): out[(j + r) % k] += row[j] / len(shifts) return out # mean log-prob of each ORIGINAL option def decide(self, state, questions, T=1.0, debias=True): res = {} for name, q in questions.items(): p = torch.softmax(self.logprobs(state, q, debias) / T, -1).tolist() item = {'probs': dict(zip(q['options'], p))} if q['type'] == 'bool': item['p_yes'] = p[0] if q['type'] == 'score': item['value'] = sum(a * b for a, b in zip(p, q['values'])) res[name] = item return res
Использование:
jev = MiniJev('Qwen/Qwen2.5-1.5B-Instruct')state = 'I was charged twice this month, please refund one of them.'print(jev.decide(state, { 'angry': Bool('Is the customer angry?'), 'team': Choice('Which team should handle it?', ['billing', 'tech support', 'sales']), 'urgency': Score('How urgent is it?'),}))
На выходе для каждого вопроса будет словарь с вероятностями по вариантам, плюс p_yes для да/нет и ожидаемое значение value для шкалы.
Как это работает внутри
-
Промпт.
_promptсобирает сообщение: инструкция, состояние, вопрос и варианты A, B, C. Шаблон чата добавляет в конец «ход ассистента», так что следующий токен — это уже начало ответа. -
Один проход.
_option_logitsпрогоняет весь батч промптов через модель и берётlogits[:, -1, :]: распределение для первого токена ответа, то есть последнюю позицию. -
Метки вариантов. Для каждой буквы берём id токенов A и A (с пробелом): в зависимости от токенизатора ответ может начаться с любого из них. Берём только те варианты, которые кодируются одним токеном.
-
Суммирование. Если у буквы два токена, их вероятности складываются. Делаем это через
logsumexp: это log(exp(a) + exp(b)), то есть сложение вероятностей прямо в логарифмах, без потери точности. -
Нормировка только по вариантам. Модель может отдать часть вероятности слову вроде The. Мы это выбрасываем и нормируем только между буквами вариантов.
-
Левый паддинг. Промпты разной длины выравниваются слева, чтобы последняя позиция у всех была настоящим концом промпта. Для моделей с RoPE сдвиг позиций из-за паддинга на результат не влияет: на крошечной случайной модели батч и одиночные запросы совпали с точностью до 3·10⁻⁸.
Борьба с позиционным смещением
Модели склонны предпочитать варианты на определённых местах списка. Параметр debias=True прогоняет все циклические сдвиги: для [‘billing’, ‘tech support’, ‘sales’] это три промпта, где буквой A по очереди оказывается каждый вариант. Затем лог-вероятности усредняются по исходным вариантам. Для да/нет это два прохода, для выбора из пяти — пять. В тесте с «подкрученной» моделью, которая бонусом любит первую позицию, без усреднения побеждал неверный вариант, а с усреднением верный.
❯ Шаг 2. Калибровка
Теперь вопрос на миллион: можно ли верить этим числам? Пока нет. Модель обучали предсказывать следующий токен, а не выдавать честные вероятности решений, поэтому 0,9 в её устах вовсе не означает «в 90% случаев я права». Типичная картина: модель слишком уверена в себе или перекошена в сторону одной метки, например «Yes».
Исправить это можно дёшево: нужно 100–300 размеченных примеров и пара строк кода.
Калибровка Платта подходит для да/нет. Берём разность лог-вероятностей z = log P(yes) − log P(no): это «уверенность» модели в логарифмическом масштабе. Затем подбираем логистическую регрессию p = σ(a·z + b) всего с двумя параметрами: a растягивает или сжимает уверенность, а b сдвигает порог.
Температурное масштабирование подходит для выбора из нескольких вариантов. У него один параметр T: вероятности считаются как softmax(s / T). При T > 1 распределение сглаживается, при T < 1 становится острее.
Качество вероятностей мерим через ECE (expected calibration error). Все предсказания раскладываются по десяти корзинам в зависимости от уверенности, и в каждой сравнивается средняя уверенность с реальной долей верных ответов. ECE — это взвешенная средняя разница. Если ECE равна 0,05, то в среднем уверенность модели отличается от реальной точности на 5 процентных пунктов.
import numpy as npfrom sklearn.linear_model import LogisticRegressionfrom scipy.optimize import minimize_scalardef ece(probs, y, bins=10): probs, y = np.asarray(probs), np.asarray(y) conf, ok = probs.max(1), probs.argmax(1) == y edges = np.linspace(0, 1, bins + 1) total = 0.0 for lo, hi in zip(edges[:-1], edges[1:]): m = (conf > lo) & (conf <= hi) if m.any(): total += m.mean() * abs(ok[m].mean() - conf[m].mean()) return float(total)class Platt: def fit(self, z, y): self.lr = LogisticRegression(C=1e6).fit(np.asarray(z).reshape(-1, 1), y) return self def predict(self, z): return self.lr.predict_proba(np.asarray(z).reshape(-1, 1))[:, 1]def fit_temperature(S, y): # S: (n, k) log-probs, y: (n,) S, y = np.asarray(S), np.asarray(y) def nll(log_t): z = S / np.exp(log_t) z = z - z.max(1, keepdims=True) lp = z - np.log(np.exp(z).sum(1, keepdims=True)) return -lp[np.arange(len(y)), y].mean() return float(np.exp(minimize_scalar(nll, bounds=(-3, 3), method='bounded').x))
Код калибровки я предварительно проверил на синтетических данных, а настоящий тест ниже.
❯ Шаг 3. Измеряем на SST-2
Возьмём SST-2 из GLUE: бинарную тональность рецензий. Из 600 примеров 200 пойдут на калибровку, 400 на проверку.
from datasets import load_datasetds = load_dataset('glue', 'sst2', split='validation').shuffle(seed=0)q = Bool('Is the sentiment of this movie review positive?')def margins(rows): out = [] for r in rows: lp = jev.logprobs(r['sentence'], q) # [log P(yes), log P(no)] out.append((lp[0] - lp[1]).item()) return np.array(out)z = margins(ds.select(range(600)))y = np.array(ds[:600]['label']) # 1 = positive = 'Yes'z_cal, y_cal, z_te, y_te = z[:200], y[:200], z[200:], y[200:]platt = Platt().fit(z_cal, y_cal)p_raw = 1 / (1 + np.exp(-z_te))p_cal = platt.predict(z_te)def report(name, p): print(name, 'acc=%.3f' % ((p > .5) == y_te).mean(), 'ECE=%.3f' % ece(np.stack([1 - p, p], 1), y_te))report('raw', p_raw)report('calibrated', p_cal)
Прогон делался в Colab. Методика:
-
датасет: GLUE SST-2, выборка validation, перемешивание с seed=0;
-
взяты первые 600 примеров: первые 200 пошли в калибровочную выборку для Platt scaling, следующие 400 стали тестовой выборкой;
-
MiniJev вычислял разность логарифмов вероятностей Yes и No, а качество вероятностей мы оценивали до и после калибровки.
Результаты на 400 тестовых примерах:
|
Метрика |
До калибровки |
После Platt scaling |
|---|---|---|
|
Accuracy |
0,927 |
0,927 |
|
ECE |
0,068 |
0,025 |
ECE снизилась примерно на 63%, а accuracy не изменилась. Это указывает на улучшение калибровки вероятностей без изменения точности классификации на этом тестовом наборе.
Но давайте без фанфар: это одна задача, одно разбиение и 400 примеров. Результат не доказывает универсального улучшения: на других данных и других моделях всё может выглядеть иначе.
❯ Шаг 4. Скорость против обычной генерации JSON
Теперь проверим главное обещание: действительно ли чтение логитов быстрее генерации. Мы сравнили два способа получить те же три ответа по одному обращению (I was charged twice this month, please refund one of them.): обычную генерацию JSON и MiniJev. Замер делался серией запусков, поэтому ниже медиана, минимум и максимум.
Обычная генерация JSON вернула:
{ "angry": true, "team": "billing", "urgency": 3}
MiniJev вернул:
{'angry': {'probs': {'Yes': 0.9996947050094604, 'No': 0.00030534894904121757}, 'p_yes': 0.9996947050094604}, 'team': {'probs': {'billing': 0.9999345541000366, 'tech support': 5.19801105838269e-05, 'sales': 1.3419315109786112e-05}}, 'urgency': {'probs': {'1': 0.7022849917411804, '2': 0.21086421608924866, '3': 0.04661112278699875, '4': 0.011071112006902695, '5': 0.029168494045734406}, 'value': 1.4539737105369568}}
Отдел поддержки модель определила как billing с вероятностью 99,99%: редкий случай, когда нейросеть не сомневается и, кажется, права. Время:
|
Способ |
Медиана |
Минимум |
Максимум |
|---|---|---|---|
|
Обычная генерация JSON |
1,0514 с |
1,0205 с |
1,3433 с |
|
MiniJev |
0,1959 с |
0,1914 с |
0,2338 с |
По медиане MiniJev быстрее в 5,37 раза. JSON при этом валиден: Python разобрал его в {‘angry’: True, ‘team’: ‘billing’, ‘urgency’: 3}.
Откуда разница? Обычная генерация выдаёт JSON токен за токеном, и каждый следующий токен ждёт предыдущего. MiniJev делает несколько параллельных проходов по промпту и читает готовые логиты. Замер сделан на коде из этой статьи при вызове decide с параметрами по умолчанию, то есть с debias=True: на три вопроса приходится 2 + 3 + 5 = 10 вариантов порядка, прогнанных тремя батчами. MiniJev выиграл, даже не экономя.
Что здесь не доказано. Мы сравнивали скорость, а не качество ответов: обратите внимание, что обычная генерация поставила срочность 3, а MiniJev выдал распределение со средним около 1,45. Какой из ответов лучше, этот замер не показывает. Это один пример и одна серия запусков, то есть не бенчмарк. Сравнения с другими моделями и сервисами мы не измеряли.
❯ Наблюдение из эксперимента: формулировка меняет оценку
Мы проверили MiniJev на том же обращении: I was charged twice this month, please refund one of them. Отдел поддержки во всех запусках уверенно определялся как billing, а вот оценка срочности заметно зависела от формулировки вопроса.
|
Формулировка вопроса о срочности |
Средняя оценка |
Наиболее вероятный вариант |
|---|---|---|
|
Исходный: How urgent is it? |
1,40 / 5 |
1 — 72,74% |
|
Вариант 1: How urgent is it to resolve this customer issue? |
4,48 / 5 |
5 — 82,14% |
|
Вариант 2: How urgently should a support agent handle this issue, from 1 (low) to 5 (critical)? |
4,48 / 5 |
5 — 82,14% |
|
Вариант 3: Assign a priority: 1=routine, 2=low, 3=normal, 4=high, 5=critical. |
1,68 / 5 |
1 — 66,50% |
На одном и том же тексте средняя оценка менялась примерно на 2,80 балла из 5. Первые два уточнённых вопроса дали одинаковое распределение, а описание шкалы через категории routine / low / normal / high / critical привело к существенно другому результату. В последнем варианте двойное списание денег получило самым вероятным ответом «routine», то есть рутину. Видимо, у клиента денег много.
Это не доказывает, что один из ответов правильный, и не является полноценным бенчмарком: мы проверили только один пример. Но эксперимент наглядно показывает ограничение zero-shot-подхода. Логиты дают удобный численный вывод, однако не гарантируют, что модель одинаково применяет критерии оценки при разных формулировках. Параметр debias=True уменьшает смещение из-за расположения вариантов, но не устраняет чувствительность к смыслу вопроса.
❯ Если качества не хватает
-
Проверить на разметке. Повторите сравнение формулировок на десятках или сотнях размеченных обращений с фиксированными критериями срочности. Так вы измерите точность и устойчивость оценок, а не будете делать вывод по одному примеру.
-
Модель побольше.
Qwen/Qwen2.5-3B-Instructвлезает в T4 без проблем. -
Несколько примеров в промпте. Это удлиняет промпт, но может помочь модели единообразно трактовать шкалу и критерии.
-
Дообучение LoRA с потерями только на логитах вариантов. Тот же однопроходный сценарий, но веса учатся делать именно это.
-
Готовая открытая модель решений для сравнения, например
d1-3Bот Liquid AI, обученная именно на такие вопросы.
❯ Итоги
За 70 строк кода и бесплатный T4 мы заставили модель отвечать в 5,37 раза быстрее обычного JSON. Насколько срочно возвращать клиенту деньги за двойное списание, она так и не определилась: от 1,4 до 4,5 из 5, в зависимости от настроения формулировки. Зато на сомнения у неё ушло всего 0,2 секунды.
Может быть интересно:
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram‑канале ↩
ссылка на оригинал статьи https://habr.com/ru/articles/1103368/