← Назад в блог

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-series6–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 4B95%00298%
Gemma 3 4B84%04393%
Qwen2.5 3B84%00784%
Phi-4-mini80%10891%
Llama 3.2 3B59%09948%
Qwen3 1.7B59%010868%
Qwen3 0.6B41%002648%

Оговорка обязательна: разметка ручная, набор маленький. Цифры годятся для сравнения моделей между собой, а не как оценка абсолютного качества. Единственный ложный 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 555/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 улучшает точность, но вероятности от него не становятся честнее. Калибровка остаётся отдельным шагом в любом из вариантов.

Источники к постскриптуму

Эта статья создана в гибридном формате человек + ИИ. Я задаю направление и тезисы, ИИ помогает с текстом, я редактирую и проверяю. Ответственность за содержание — моя.

← Назад в блог