L2-классификатор рисков: читать логиты вместо генерации
Система оценки рисков в Open Cradle двухслойная. L1 — детерминированные правила, отрабатывают быстрее 10 мс. L2 — LLM-классификатор. Итоговый уровень — finalLevel = max(L1, L2): что бы ни сказала модель, жёлтый от правил ниже не опустится.
Проблема была в L2. Модель писала JSON под grammar: категория, риск, confidence, reasoning. На Qwen3 4B и M-series это занимало 6–7 секунд на тикет, а поле confidence модель придумывала сама. Число в ответе было, смысла в нём не было.
Гипотеза
Классификация — закрытый вопрос. Для закрытого вопроса модели не нужно ничего писать: достаточно одного forward pass и распределения следующего токена по допустимым ответам. Чтение логитов — известная техника, ничего нового я здесь не изобрёл. Интересно было, что сломается при переносе её в продукт на маленьких локальных моделях.
Примитив decide()
Вариантам ответа даются однотокенные псевдонимы: буквы A, B, C… для выбора и булевых вопросов, цифры для шкалы. Runner оборачивает промпт в chat template модели, оставляет ход ассистента открытым и возвращает массу вероятности на каждом кандидате.
Три величины на каждый ответ:
probabilities— масса, перенормированная по вариантам;coverage— сумма массы на вариантах до нормировки;confidence = p(выбранного) × coverage.
Текст тикета стоит в промпте первым. Вычисленный префикс переиспользуется между вопросами одного запроса, поэтому второй и следующие вопросы стоят дёшево.
import { decide } from '@cradle/core/decision'
const res = await decide(runner, {
// The ticket goes first: its evaluated prefix is shared by every question.
state: ticketText,
// System turn: definitions of risks and categories. No "return JSON" lines.
context: logitsContext(operatorPrompt),
questions: {
risk: {
type: 'choice',
instructions: 'What is the risk level of this message?',
choices: {
green: 'safe to answer automatically',
yellow: 'an operator must review the answer',
red: 'always needs approval'
}
},
commitment: {
type: 'boolean',
instructions: 'Does the message ask us to take on a financial or legal commitment?'
},
urgency: {
type: 'score',
instructions: 'How urgent is this message?',
min: 0,
max: 5
}
}
}, { debug: true })
res.method // 'logits' | 'generated'
res.answers.risk.probabilities // { green, yellow, red } — sums to 1
res.answers.risk.coverage // mass on A/B/C before renormalisation
res.answers.risk.confidence // p(chosen) × coverage
res.answers.commitment.probability // P(yes)
res.answers.urgency.value // expected value over 0..5
res.answers.urgency.mode // most probable grade
res.calibrated // false — raw model output
Если runner не умеет отдавать логиты (внешний API), decide() переходит на путь generated: ответ под enum-схемой, probabilities: null.
Что получилось по времени
| Вариант | Время |
|---|---|
| Генерация JSON, Qwen3 4B, M-series | 6–7 с |
| Логиты, 5 вопросов | 987 мс |
| Логиты, 6 вопросов (с red-флагами) | 1674 мс |
| + вопрос «кто исполняет» на общем префиксе | +410 мс |
Грабли
1. Промпт просил JSON. Операторский промпт для старого пути заканчивался инструкцией «верни JSON». На пути логитов модель послушно начинала ответ с {, и coverage был 0,00–0,45. Строки про формат ответа из контекста убраны, определения рисков и категорий оставлены.
2. Reasoning-модели начинают с <think>. Если это самый вероятный следующий токен, runner дописывает пустой блок размышлений и читает распределение уже после него. На Qwen3 срабатывает на каждом вопросе.
3. Coverage 1,0 — не значит правильно. Контекст по умолчанию «ответь одной буквой» дал coverage 1,0, но модели 0.6B и 1.7B стали почти всегда выбирать последний вариант. Откатил. Coverage показывает соблюдение формата, а не правильность ответа.
4. Один вопрос на три уровня пропускал red. На «подпишите допсоглашение» Qwen3 4B отвечала yellow с p = 1,00. Решение — три отдельных boolean red-флага: финансовое или юридическое обязательство; удаление, доступы, прод; персональные данные третьих лиц. Любое «да» с P ≥ 0,5 делает вердикт red. Модель, которая ошибается в уровне, обычно узнаёт факт, если спросить о нём прямо.
5. Риск выбирается не по argmax. Цена ложного green (автоответ без проверки) выше цены лишнего yellow (оператор посмотрит). Поэтому пороги:
function pickRisk(p: Record<'green' | 'yellow' | 'red', number>) {
if (p.red >= 0.3) return 'red' // red as soon as P(red) reaches 0.3
if (p.green >= 0.8) return 'green' // green only when P(green) reaches 0.8
return 'yellow' // everything in between
}
6. Fallback не может вернуть green. Если coverage риска или категории ниже 0,5, классификатор уходит на старый генеративный путь. Но вердикт из fallback не может быть green: prompt injection на Phi-4-mini дал именно такой ложный green. Отказ модели от закрытого вопроса — сам по себе признак, что сообщение необычное.
Замеры
44 размеченных вручную тикета, квантование Q4_K_M. Колонки: точность по риску, ложных green, пропущенных red (yellow вместо red — тикет всё равно попадёт к оператору), завышенных уровней, точность по категории.
| Модель | Риск | Ложный green | Пропущен red | Завышен | Категория |
|---|---|---|---|---|---|
| Qwen3 4B | 95% | 0 | 0 | 2 | 98% |
| Gemma 3 4B | 84% | 0 | 4 | 3 | 93% |
| Qwen2.5 3B | 84% | 0 | 0 | 7 | 84% |
| Phi-4-mini | 80% | 1 | 0 | 8 | 91% |
| Llama 3.2 3B | 59% | 0 | 9 | 9 | 48% |
| Qwen3 1.7B | 59% | 0 | 10 | 8 | 68% |
| Qwen3 0.6B | 41% | 0 | 0 | 26 | 48% |
Оговорка обязательна: разметка ручная, набор маленький. Цифры годятся для сравнения моделей между собой, а не как оценка абсолютного качества. Единственный ложный green во всей таблице — тот самый prompt injection на Phi-4-mini, из-за которого появилось правило номер шесть.
Ограничения
- Вероятности не откалиброваны. Уверенная модель ошибается с p = 1,0, и порог этого не ловит. В ответе так и стоит
calibrated: false. - Позиционный сдвиг у маленьких моделей никак не компенсируется.
- Red-флаги настроены под одну модель. На другой формулировки и порог надо проверять на наборе заново.
Дальше: temperature scaling на каждый вопрос с разметкой из решений оператора, метрика ECE, усреднение по перестановкам вариантов против позиционного сдвига. Только после этого пороги можно будет подбирать честно, а не на глаз.
Постскриптум: а как же Jev?
Раздел добавлен после публикации. Когда статья уже была написана, TypeSafe AI выпустила Jev — модель, которая не генерирует текст, а возвращает типизированные решения: выбор из списка, оценку по шкале, да/нет с вероятностью. По сути это тот же примитив, что мой decide(), только продаваемый как hosted API. Не сравнить себя с рыночным решением было бы странно, так что вот сравнение.
Секрета в Jev нет. Деньги берут за три вещи.
Обучение под решения. Вероятности заявлены как калиброванные: TypeSafe описывает отдельный метод обучения, при котором «0,8» должно означать «правы примерно в 80% таких случаев». У меня логиты генеративной модели, и калибровать их надо самому на своих данных.
Скорость и цена. Заявленная латентность 70–500 мс end-to-end, вопросы к одному состоянию считаются параллельно. Вход стоит $0,042 за миллион токенов, выход не тарифицируется. Свой GPU не нужен.
Готовая обвязка. Параллельная оценка нескольких вопросов, сцепление классификаций в цепочку, SDK. У меня это код, который надо писать и поддерживать.
Рынок это принял быстро: по данным Vercel, за первые сутки на AI Gateway Jev подключили почти 13% платящих команд, больше, чем любой другой запуск модели. Для меня это аргумент в пользу подхода, а не продукта: индустрия пришла к тому же выводу, что классификация — закрытый вопрос, а не генерация.
Что показали независимые проверки, пока их немного:
| Проверка | Что мерили | Результат |
|---|---|---|
| jev-benchmark (60 кейсов, риск tool-call, 4 класса) | точность и калибровка против Claude Sonnet 5 | 55/60 у всех; Jev в 3,25–3,62× быстрее; все ошибки шли с неуверенным confidence |
| jev-ood-calibration (900 синтетических тикетов + 3 публичных бенчмарка) | ECE на незнакомой задаче | 0,107 при шумовом пороге 0,024; на публичных бенчмарках 0,024–0,032 |
| does-jev-confidence-mean-anything (8000 суждений, civil_comments) | ECE и сдвиг вероятностей | сдвиг к «да», ECE 0,209 до и 0,006–0,023 после двухпараметрической перекалибровки; AUC 0,91 |
| Banking77 и похожая 77-классовая маршрутизация | точность против frontier-моделей | около 78% у Jev против 84–86% у frontier-моделей |
Вывод из таблицы для меня важнее любых маркетинговых цифр: калибровка Jev держится на задачах, похожих на обучающие, и расплывается там, где метка зависит от политики компании, а не от текста. Риск тикета в закрытом контуре — ровно такая задача. Перекалибровка на своих данных нужна и Jev, и мне; разница в том, что у Jev стартовая точка лучше.
Где выигрывает локальный путь:
- Контур. Jev доступен только как hosted API (TypeSafe, Vercel AI Gateway, OpenRouter, Cloudflare), веса не публикуются, on-premise варианта нет. Для закрытого контура это отсекает его сразу, без обсуждения качества.
- Понимание контекста. На сложных многоклассовых задачах frontier-модели точнее Jev на 6–8 пунктов. Локальная модель 4B не frontier, но она читает весь операторский промпт с определениями рисков, а не фиксированную схему.
- Правила правит оператор. Определения уровней риска живут в промпте и меняются без переобучения.
- Железо уже оплачено. Qwen3 4B на M-series даёт 987 мс на пять вопросов; это медленнее Jev, но стоимость запроса — ноль.
Итог для принимающего решение: Jev продаёт удобство и калибровку тем, у кого нет своего GPU и нет запрета на внешний API. В закрытом контуре оба условия обратные, поэтому локальный путь здесь правильный, а Jev — ориентир, по которому его стоит мерить.
Альтернативы, если Jev нельзя
Четыре способа получить решение с вероятностью внутри контура. Замеров по трём последним у меня нет; это план, а не отчёт.
| Вариант | Данные в контуре | Калибровка | Новая категория | Что нужно для старта |
|---|---|---|---|---|
| Логиты генеративной модели (текущий путь) | да | нет, нужен temperature scaling | правка промпта | модель 4B и размеченные решения оператора |
| Дообучение Qwen (LoRA) или маленький encoder-классификатор | да | приходит из обучения, но дрейфует | переобучение | сотни примеров на класс, конвейер переобучения |
| RAG: похожие тикеты с вердиктами оператора как примеры в контексте | да | нет, RAG повышает точность, не честность вероятностей | добавить примеры в базу | индекс по истории тикетов |
| Jev | нет | заявлена, на своих задачах проверять | правка схемы вопроса | внешний API и разрешение на него |
Дообучить Qwen. Если взять решения оператора из risk_assessments как разметку, LoRA на Qwen3 4B или даже обычный encoder-классификатор дадут то, что Jev продаёт из коробки: вероятности, выученные под задачу, и латентность в десятки миллисекунд. Цена: сотни примеров на класс, а не 44 тикета на всё; каждая новая категория или изменение политики — переобучение; операторский промпт перестаёт быть источником правды. Порядок действий отсюда очевиден: сначала логиты, они работают с первого дня и копят разметку, потом дообучение на накопленном.
RAG от внутренней базы знаний. Для классификации это не поиск документов, а поиск похожих тикетов с уже вынесенными вердиктами и подстановка их в контекст как примеров. Модель получает «вот пять похожих обращений и что с ними сделал оператор» и отвечает на тот же закрытый вопрос. Это адаптация к домену без обучения, правила по-прежнему редактируемые, а примеры появляются сами по мере работы. Оговорки: примеры удлиняют префикс (он считается один раз на тикет, так что для пяти вопросов это терпимо); извлечённый текст — ещё один канал для prompt injection; на маленьких моделях примеры усиливают позиционный сдвиг. И главное: RAG улучшает точность, но вероятности от него не становятся честнее. Калибровка остаётся отдельным шагом в любом из вариантов.
Источники к постскриптуму
- OpenRouter, What Is Jev? TypeSafe's Decision Model Explained — что возвращает модель, латентность, параллельная оценка вопросов.
- MindStudio, Jev AI Pricing Explained — $0,042 за миллион входных токенов, бесплатный выход.
- Vercel, Jev is the fastest-adopted model in AI Gateway history — почти 13% платящих команд за сутки.
- themsquared/jev-benchmark — 60 кейсов, риск tool-call.
- scienthoon/jev-ood-calibration — ECE на незнакомой задаче.
- Adilmp/does-jev-confidence-mean-anything — 8000 суждений, сдвиг к «да».
- Failproof AI, Can you self-host Jev? — только hosted API, весов нет.
- DEV Community, Jev after eight days of independent tests — Banking77 и сравнение с frontier-моделями.