← Назад в блог

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, усреднение по перестановкам вариантов против позиционного сдвига. Только после этого пороги можно будет подбирать честно, а не на глаз.

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

← Назад в блог