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