В первой статье мы разобрали, что такое Jev и как устроен его протокол: три типа вопросов, вероятности вместо меток, сотни миллисекунд на запрос. Здесь — про то, где это окупается. Семь сценариев, в каждом указано, что спрашивать, как складывать ответ в решение и на чём легко обжечься.

Правило нулевое: сначала код

Руководство TypeSafe открывается требованием, которое звучит почти обидно для статьи про модель: если задачу можно решить кодом — решайте кодом. Проверка домена по списку, длина строки, наличие поля, сумма больше лимита — всё это регулярное выражение или условие, и модель тут лишняя.

Модель нужна ровно там, где решение зависит от смысла: «это жалоба или вопрос», «этот запрос сложнее предыдущего», «эта команда выглядит опасной». Смешивать не надо: там, где есть правило, правило дешевле, быстрее и не ошибается.

1. Предохранитель на действия ИИ-агента

Самый очевидный сценарий. Агент собрался выполнить действие: удалить файлы, отправить письмо, сделать запрос к внешнему адресу. Между намерением и выполнением ставится проверка, которая стоит доли цента и занимает полсекунды. В витрине рецептов OpenRouter этот сценарий проверяет 96 вызовов инструментов за 0,5 секунды и 0,0004 доллара за прогон.

Ключевой приём — спрашивать о фактах, а не о разрешении.

Вопросы о фактах, решение — в коде

questions: {
  destructive: { type: 'noul',
    instructions: 'Команда удаляет или необратимо меняет данные.' },
  outside_scope: { type: 'noul',
    instructions: 'Действие выходит за рамки того, о чём просил пользователь.' },
  external_write: { type: 'noul',
    instructions: 'Действие отправляет данные наружу: письмо, вебхук, запрос к чужому API.' },
  secrets: { type: 'noul',
    instructions: 'В аргументах есть пароль, токен или ключ доступа.' }
}

Пороги — ваши, и они видны в коде

const a = await ask(state, questions)

if (a.secrets.noul > 0.5) return block('в аргументах секрет')
if (a.destructive.noul > 0.8 && a.outside_scope.noul > 0.5) return block('разрушительно и вне задачи')
if (a.destructive.noul > 0.5 || a.external_write.noul > 0.7) return askHuman()
return allow()

Почему не спросить прямо «стоит ли одобрить»? Потому что тогда политика безопасности переезжает внутрь модели, и на вопрос «почему заблокировали» ответить нечем. В варианте выше политика — восемь строк кода, которые можно показать, обсудить и поменять без переобучения чего бы то ни было.

2. Каскад: дешёвая модель пишет, Jev проверяет

Схема из рецептов TypeSafe, и, пожалуй, самая выгодная из всех. Черновик делает дешёвая модель. Дальше Jev проверяет черновик на соответствие исходным данным. Если проверка не прошла — задача уходит к сильной модели.

Каскад из трёх шагов

const draft = await cheapModel(question, context)

const v = await ask(
  { вопрос: question, контекст: context, черновик: draft },
  {
    supported:  { type: 'noul', instructions: 'Каждое утверждение черновика подтверждается контекстом.' },
    invented:   { type: 'noul', instructions: 'В черновике есть факты, которых в контексте нет.' },
    answers:    { type: 'noul', instructions: 'Черновик отвечает именно на заданный вопрос.' },
  })

const ok = v.supported.noul > 0.85 && v.invented.noul < 0.15 && v.answers.noul > 0.8
return ok ? draft : await strongModel(question, context)

В рецепте OpenRouter такой каскад даёт столько же правильных ответов, сколько сильная модель на всём потоке, — примерно за 7% её стоимости. Цифра из первоисточника, и она держится на простом соображении: большинство запросов простые, а дорого обходится не средний запрос, а решение отправлять все запросы в самую дорогую модель.

Обратите внимание на вопрос про выдуманные факты. Он задан отдельно от вопроса про подтверждённость — и это не дублирование. Черновик может пересказать контекст верно и при этом дописать от себя пару правдоподобных деталей; одним вопросом такое не ловится.

3. Сортировка входящих обращений

Классика: поток писем или сообщений, каждое нужно разложить по темам, срочности и тону. Витринный рецепт разбирает 475 ответов за 1,2 секунды — это примерно сотня обращений, у каждого по нескольку признаков.

Признаки обращения

{
  тема: { type: 'choice',
    instructions: 'О чём обращение.',
    criteria: { оплата: 'счета, возвраты, списания',
                доступ:  'вход, пароль, права',
                ошибка:  'что-то не работает',
                вопрос:  'как пользоваться' } },
  срочность: { type: 'score',
    instructions: 'Насколько срочно нужен ответ.',
    criteria: ['Может подождать неделю', 'Обычная очередь', 'Горит, клиент заблокирован'] },
  зол: { type: 'noul', instructions: 'Клиент раздражён или угрожает уйти.' },
  повтор: { type: 'noul', instructions: 'Клиент пишет об этом не в первый раз.' }
}

Дальше — обычная маршрутизация кодом: «зол и повтор» отправляется руководителю группы, «оплата и срочность выше 1,5» — в финансы, остальное в общую очередь. Важная деталь: низкая уверенность по теме должна вести не в случайную очередь, а в общую — пусть разберёт человек.

4. Извлечение полей без догадок

Вытащить из документа номер, дату, сумму, контрагента — задача, на которой языковые модели чаще всего тихо выдумывают. Модель не любит отвечать «не знаю», и на месте отсутствующего ИНН появляется правдоподобная последовательность цифр.

Приём против этого — сначала спросить о наличии, и только потом извлекать.

Сначала наличие, потом значение

// шаг 1 — одним запросом обо всех полях сразу
{
  есть_инн:   { type: 'noul', instructions: 'В документе явно указан ИНН.' },
  есть_дата:  { type: 'noul', instructions: 'В документе явно указана дата документа.' },
  есть_сумма: { type: 'noul', instructions: 'В документе явно указана итоговая сумма.' }
}

// шаг 2 — извлекаем только то, что точно есть
const fields = Object.entries(a)
  .filter(([, v]) => v.noul > 0.8)
  .map(([k]) => k.replace('есть_', ''))

Поле, про которое модель сказала «нет», не надо извлекать вовсе — его надо пометить как отсутствующее и показать человеку. Пустое поле честнее выдуманного: первое заметят, второе уйдёт в базу и всплывёт через полгода.

5. Модерация и фильтр ленты

Сорок постов за полсекунды и три десятитысячных доллара — витринный рецепт фильтра ленты. Здесь Jev хорош ровно тем, что возвращает распределение: у модерации почти всегда есть серая зона, и её нужно не решать, а отдавать человеку.

Три исхода вместо двух

if (a.violates.noul > 0.9)  return hide()
if (a.violates.noul < 0.3)  return show()
return queueForReview()   // серая зона — человеку

Разница с обычным классификатором в том, что ширину серой зоны вы задаёте сами и можете менять её под нагрузку: на праздниках, когда модераторов меньше, зону сужают.

6. Маршрутизация запроса на подходящую модель

Сценарий, ради которого мы вообще взялись за замеры. В чате пользователь пишет что угодно: то «привет», то просьбу разобрать план выполнения запроса по таблице на сорок миллионов строк. Отправлять и то и другое в самую сильную модель — дорого, в самую дешёвую — стыдно.

Шесть вопросов о новом сообщении

{
  нужна_модель_сильнее: { type: 'noul',
    instructions: 'Текущая модель слабовата: задача требует более сильной, иначе ответ будет заметно хуже.' },
  тип_задачи: { type: 'choice', instructions: 'К какому типу относится сообщение.',
    criteria: { код: '...', текст: '...', анализ: '...', болтовня: '...' } },
  глубина_раздумий: { type: 'score',
    instructions: 'Насколько глубоко нужно обдумывать ответ.',
    criteria: ['Отвечать сразу', 'Немного подумать', 'Думать долго и тщательно'] },
  сменилась_тема: { type: 'noul', instructions: 'Сообщение сменило тему разговора.' },
  нужен_поиск: { type: 'noul', instructions: 'Нужны свежие данные из интернета.' },
  нужен_запуск_кода: { type: 'noul', instructions: 'Полезно выполнить код в песочнице.' }
}

Именно этот запрос мы прогоняли: 902 входных токена, шесть ответов, 0,000038 доллара, от 495 до 785 миллисекунд с российского сервера через прокси. На запросе про SQL после разговора о шрифтах модель дала «код» с уверенностью 1, смену темы 0,98, глубину 1,78 из 2 и поиск в интернете 0,08.

7. Интерфейсы, которые отвечают мгновенно

Восемь ответов за 300 миллисекунд на проверке формы заказа и два-три хода в секунду в игре — это уже другой класс задач. Там, где обычная модель отвечает секунды, интерфейс приходится строить вокруг ожидания: спиннеры, «печатает», отложенная проверка. На трёхстах миллисекундах ожидание исчезает, и проверку можно делать прямо по мере заполнения поля.

Оговорка: 300 миллисекунд — это из дата-центра к дата-центру. Из России через прокси наш замер дал 495–785 миллисекунд, и на интерактивном сценарии такую разницу уже видно. Прежде чем строить интерфейс вокруг мгновенности, померяйте с того места, где он будет работать.

Шесть правил, по которым стоит проектировать вопросы

  1. Атомарность. Один вопрос — одно утверждение. «Текст грубый и не по теме» — это два вопроса, и ответ на такой сдвоенный вопрос нельзя истолковать.
  2. Утверждение, а не вопрос. В instructions пишется то, что может быть верным или неверным: «Клиент раздражён», а не «Раздражён ли клиент?».
  3. Факты, а не политика. Спрашивайте о свойствах того, что видите. Решение по свойствам принимает код.
  4. Много вопросов за раз. Десять вопросов стоят почти столько же, сколько один: платите за состояние, а его передали однажды.
  5. Состояние — только нужное. Лишние поля в state — это входные токены, время и лишний повод модели отвлечься.
  6. Низкая уверенность — не повод угадать. Это повод передать выше: человеку, модели посильнее или в общую очередь.

Грабли, на которые мы наступили

СимптомПричина
400 без внятного текстаНе передан instructions — он обязателен у всех трёх типов
400 на choicecriteria передан массивом или строкой, а нужен объект «вариант: описание»
400 на scorecriteria передан объектом, а нужен массив, упорядоченный снизу вверх
Ответы разъезжаются от запуска к запускуСлишком широкое состояние или сдвоенные утверждения в одном вопросе
Иногда запрос висит до таймаутаПризнак альфа-эндпоинта; стабильный путь — /api/v1/systemone, и ставьте повтор
Что ломается на практике

Отдельно: текст ошибки в ответе на 400 подробный — там прямо указан путь до поля и ожидаемый тип. Не глотайте его в логах, он экономит часы.

С чего начать

Возьмите одно место в своём продукте, где сейчас стоит языковая модель, а нужно от неё ровно одно решение. Чаще всего это классификатор, предохранитель или выбор ветки. Перепишите его на несколько атомарных вопросов, оставьте пороги в коде и померяйте две вещи: сколько стало стоить и сколько занимает времени из той точки, где это будет работать.

Если решение зависит от смысла, но ответ не обязан быть текстом — скорее всего, вы нашли подходящее место.


Первая часть — про устройство модели, протокол, цены и наши замеры: «Jev: модель, которая не пишет текст, а принимает решения».