Один пишет, семеро проверяют: как мы генерируем тестовые вопросы через AI — и почему это не «просто ChatGPT»

Содержание13
  1. Точка отсчёта: аудит, который нас отрезвил
  2. Главный принцип: генератор никогда не проверяет сам себя
  3. До генерации: чистим вход и планируем покрытие
  4. Генерация: не «напиши вопрос», а техзадание с обвесом
  5. ОТК: панель валидаторов
  6. Что происходит с браком: ретраи с памятью и ремонт утечки
  7. Дубликаты и умение остановиться
  8. Режим второй: вопросы по вашим регламентам, а не по «интернету вообще»
  9. Человек в контуре
  10. Музей граблей
  11. Экономика качества и честные статусы
  12. Что мы вам не рассказали
  13. Частые вопросы

Разбор пайплайна Codenrock Vibe: планировщик покрытия, факт-чекер, слепой калибратор сложности, симулятор кандидата — и грабли, на которых всё это выросло.

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

Вот типичный вопрос из той эпохи (пример стилизованный, но дефект настоящий):

Как называется паттерн, при котором объект создаётся единственный раз, а все обращения идут к одному общему экземпляру?

A) Singleton B) Factory Method C) Observer D) Builder

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

Точка отсчёта: аудит, который нас отрезвил

Первая версия была ровно тем, что мы теперь критикуем: один вызов LLM, один промпт, ноль проверок. Потом эксперт вручную разобрал сгенерированный банк из 59 вопросов — и нашёл 12 системных типов брака. 14 из 19 вопросов по Python оказались про одну и ту же подтему (генераторы и ленивая обработка файлов), включая шесть открытых вопросов с одинаковым описанием подряд. Верные варианты были помечены неверными. В коде «с багом» бага не существовало. Тривиальные задачи гордо носили метку «сложно». Запреты из описания компетенции игнорировались. Отклонений — ноль, потому что проверки не существовало.

Отдельного упоминания заслуживает reasoning-режим, на котором всё это работало: генератор запускался с минимальным reasoning-усилием — модель буквально не думала. Одно только включение полноценного reasoning подняло долю прохождения валидации с 52% до 68%.

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

Главный принцип: генератор никогда не проверяет сам себя

Ключевое архитектурное решение — adversarial-разделение. Один AI пишет вопрос, независимые reasoning-модели других вендоров его атакуют, а финальное решение «принять / отклонить / переделать» принимает вообще не модель, а детерминированный код. У разных семейств моделей разные слепые зоны, и модель склонна прощать собственные ошибки — поэтому кросс-вендорная проверка зафиксирована в коде как инвариант, а не как пожелание.

Конвейер одного вопроса выглядит так: препроцессор компетенции → планировщик покрытия → генератор → структурный валидатор → факт-чекер → калибратор сложности → симулятор кандидата → детерминированный агрегатор вердиктов → инкрементальная дедупликация, плюс отдельный агент точечного ремонта утечек. Кто из них нейросеть, а кто код — принципиально, поэтому вот карта конвейера:

Схема конвейера генерации вопроса: препроцессор, планировщик покрытия, генератор, четыре гейта проверки, агрегатор вердиктов и дедупликация. Цветом размечено, какие стадии — LLM, а какие — детерминированный код
Фиолетовые стадии — LLM-роли, тёмные — детерминированный код. Два из семи проверяющих вообще не нейросети — и это принципиально.

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

До генерации: чистим вход и планируем покрытие

Препроцессор компетенции. Описание компетенции — свободный текст, и пользователи пишут туда далеко не только суть навыка (самый дорогой инцидент на эту тему — в «Музее граблей» ниже). Показательный случай: почти все вопросы Python-разработчику оказались про телеметрию роботов — в описание компетенции был вшит контекст компании, и генератор исправно тащил его в каждый вопрос.

Теперь описание предварительно разделяется на «что тестируем» (уходит генератору) и «инструкции и запреты» (уходят проверяющему на контроль соблюдения). Заодно это защита от prompt-injection: эвристики ловят и русские, и английские паттерны инъекций.

Планировщик покрытия. Разнообразие не оставляется на волю случая. До генерации компетенция декомпозируется на аспекты в два прохода: сначала генерация аспектов «по граням», затем отдельный LLM-критик разнообразия находит близнецов — один навык разными словами — и заменяет их аспектами из непокрытых граней. Любопытная деталь: от эмбеддингов здесь мы отказались — классические метрики близости на наших данных разделяли соседние аспекты хуже, чем LLM-критик. Единица разнообразия — пара «аспект × тип вопроса»: один аспект другим типом — это глубина, а не дубль. Если различимых граней меньше плана, система возвращает меньше, а не выдумывает натянутые темы. И отдельный дешёвый вызов решает, уместен ли вообще код: для «коммуникации» и «лидерства» кодовые слоты автоматически конвертируются в открытые вопросы — кодовых задач про переговоры не рождается.

Генерация: не «напиши вопрос», а техзадание с обвесом

Генератор получает не голую тему, а пакет: предметную часть компетенции, назначенный аспект, тип и сложность, правила качества, собранные слоями — универсальные, по типу вопроса, по компетенции, — список уже существующих вопросов, снимок «собратьев по батчу» и — в режиме базы знаний — фрагменты документов клиента. Правила качества — это код, а не пожелание «пиши хорошо». Несколько примеров категорий:

  • «вопрос-ЗАДАЧА, а не вопрос-ОПРЕДЕЛЕНИЕ»: если на вопрос можно ответить, перечитав его и не зная предмета, — это утечка;
  • психометрическая деталь: дистрактор не должен повторять редкое слово из формулировки — это статистическая подсказка;
  • компетенция-зависимые правила: для «Тестирования» кодовые задания обязаны требовать написания тестов, для «ООП» — классы, плюс «антименеджерский фильтр» для технических компетенций — не спрашивать про спринты там, где надо писать код;
  • детерминизм кода: запрет timing-зависимого asyncio и random без seed.

Параллельные генераторы «видят собратьев»: назначения перемешаны round-robin по компетенциям, и каждому подкладываются краткие описания уже созданных в батче вопросов — иначе «слепой залп» рожает близнецов. У блоков правил есть бюджет по символам, контролируемый тестами, — промпт не разбухает. Всего в системе около 58 runtime-промптов, каталогизированных с метаданными и уровнем риска редактирования; JSON-контракт ответа не может изменить даже владелец проекта.

Снаружи вся эта машинерия выглядит как один аккуратный диалог:

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

ОТК: панель валидаторов

Нулевой этаж — структурный валидатор, ноль LLM-вызовов. Детерминированный ранний гейт: полнота вариантов и правильного ответа, длина вариантов, «вопрос спрятан в комментариях кода», «консольный вывод склеен в одну строку», блок-лист нерелевантной доменной терминологии — тот самый кейс с телеметрией роботов из раздела про препроцессор, закреплённый теперь ещё и детерминированной проверкой. Сработал — дорогие агенты даже не запускаются.

Факт-чекер — до шести независимых осей проверки; набор зависит от типа вопроса: от трассировки кода — существует ли заявленный баг вообще — до корректности каждого варианта по отдельности («дистрактор тоже верен» — тоже брак).

Слепой калибратор сложности. Ему намеренно не показывают заявленную сложность — он независимо оценивает уровень и прогнозирует поведение кандидатов на вопросе. Существенное расхождение с заявкой — отклонение с конкретной подсказкой «сделай значительно сложнее/проще». Ловит классику: тривиальный вопрос с меткой «сложный».

Симулятор кандидата (максимальный режим качества) — самый наглядный агент. Модель проходит вопрос как сильный кандидат, не видя разметки. Если она уверенно выбирает вариант, помеченный неверным, и внятно это обосновывает — вопрос неоднозначен, брак. Для задач «найди баг» симулятор иногда отвечает: «код и так корректен» — значит, баг фейковый. Стилизованный пример вердикта:

Задание: «Найдите баг в функции пагинации». Симулятор: «Функция корректна для всех граничных случаев, включая пустой список и последнюю неполную страницу. Заявленного бага не существует». → код проблемы FAKE_BUG, реджект.

Агрегатор вердиктов — принципиально без LLM. Решение принимает детерминированный код: воспроизводимость важнее «интеллектуальности». Схематично — без реальных кодов, порогов и весов, они часть продукта:

problems = collect_all(validators)          # один проход, все проблемы сразу
problems -= { p | p.code == TOOL_FAILURE }  # сбой инструмента не влияет на вердикт
if exists p: p.fatal and p.confident   -> REJECT(reasons = problems)
if exists p: not p.confident           -> RETRY(hints = problems)   # сомнение — второй шанс, а не расстрел
-> PASS

Строка про TOOL_FAILURE — выстраданный инвариант: сбой самого инструмента проверки не может забраковать вопрос — поломка линейки не делает деталь бракованной.

Выборка кодов проблем (из более чем двух десятков): ANSWER_LEAKED, FAKE_BUG, QUESTION_IN_CODE, NON_DETERMINISTIC_CODE, SEMANTIC_DUPLICATE, WRONG_ANSWER_MARKED.

Что происходит с браком: ретраи с памятью и ремонт утечки

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

Самая частая причина брака — утечка ответа — лечится отдельным механизмом. Если утечка — единственная причина реджекта, вопрос не перегенерируется с нуля: полная регенерация обычно «течёт» снова. Вместо этого дешёвый агент точечно ремонтирует формулировку под жёсткими инвариантами неизменности, после чего вопрос проходит укороченную ре-валидацию. Результат: выход банка вырос с ~72% до ~86%, при этом принятый вопрос не стал дороже.

И контринтуитивный A/B-эксперимент: глубина рассуждений утечку НЕ вылечила — pass rate 0.27 против 0.26, на нашей выборке разница не выходит за пределы шума. Утечка лечится правилами формулировки, а не «думанием». Полезный тезис против карго-культа reasoning.

Дубликаты и умение остановиться

Дедупликация — семантическая, двухуровневая: быстрый фильтр на эмбеддингах отбирает подозрительные пары, затем LLM подтверждает «тот же навык, другой словесный камуфляж» (при недоступности эмбеддингов — fallback на Jaccard). Ключевая идея — таксономия когнитивных уровней: два вопроса про одну тему НЕ дубликаты, если тестируют разные уровни — знание, понимание, применение, анализ. Дубль — совпадение и темы, и уровня.

Поверх работает механизм «насыщения»: если доля дублей по компетенции превысила порог, генерация останавливается с явным статусом — «создано N из M, тема исчерпана» — вместо оплаты заведомого брака. В старой версии был анти-паттерн: при переполненном банке система сама просила модель «сделать вариацию того же навыка» — и сама же удаляла результат как дубликат. Мы платили за мусор, который сами и заказали.

Режим второй: вопросы по вашим регламентам, а не по «интернету вообще»

У пайплайна два равноправных источника знаний. Режим первый — знания фронтирных моделей: компетенции общего рынка вроде Python, ООП, коммуникации. Режим второй — база знаний клиента: внутренние регламенты, процессы, продукты, по которым ни одна публичная модель ничего не знает.

Схема двух источников знаний: слева — компетенции общего рынка на знаниях фронтирных моделей, справа — конвейер обработки документов компании; оба сходятся в общий генератор с панелью валидаторов
Какой бы ни был источник, контроль качества один и тот же: скидок на «сложный режим» нет.

Ингест. Загружаются PDF, DOCX, XLSX, TXT и веб-страницы (версионирование по SHA-256, защита от SSRF); обработка — асинхронный конвейер из пяти стадий: скачивание → парсинг → чанкинг → эмбеддинги → сохранение. Парсер восстанавливает структуру даже из плоского текста четырьмя эвристиками заголовков, включая кириллический капс; таблицы выделяются в отдельные «ассеты». Чанкинг структурно-осознанный, с перехлёстом между соседними чанками — мысль не рвётся на границе.

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

Grounding. Поиск двухступенчатый: сначала по документам, явно привязанным к компетенции (с приоритетом и флагом «обязательный источник»). Если привязанных источников нет — fallback на все базы проекта. А вот если источники привязаны, но поиск по ним ничего не нашёл, — генерация идёт без grounding’а, с зафиксированным режимом и предупреждением, а не притворяется обоснованной.

Диалог привязки документа к компетенции: выбор профессии и компетенции, к которым относится регламент
Привязка документа к конкретной компетенции: именно эти фрагменты будут в приоритете, когда генератор возьмётся за вопросы по ней.

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

Без базы знаний: «В какой срок по закону потребитель может вернуть товар надлежащего качества?» — вопрос про интернет вообще.

С базой знаний: «Клиент требует возврат на 20-й день, ссылаясь на акцию. Как по внутреннему регламенту возвратов обрабатывается такой кейс и кто согласует исключение?» — источник: «Регламент возвратов, раздел 4».

Деградация без падения. Если векторное расширение БД недоступно — молчаливый пересчёт косинусов на Node.js; если упали эмбеддинги — чанки остаются доступными текстовому поиску. Пайплайн нигде не падает целиком из-за отказа одной подсистемы.

Человек в контуре

Карточка сгенерированного вопроса в банке: тип, сложность, полное условие задачи на конвертацию кода и сам код
Карточка вопроса, которую видит эксперт: тип, сложность, условие, код — и кнопки правки. До кандидата вопрос доедет только после этой витрины.

AI-проверки — не замена эксперту, а фильтр перед ним. Эксперт ставит APPROVE/REJECT с одной из 13 категорий («Ответ виден в коде», «Спорный правильный ответ»…) и комментарием. Дальше фидбэк замыкается в обучающий контур: топ категорий отклонений по компетенции автоматически подмешивается в промпты всех будущих генераций — «ранее отклонено экспертом, не повторяй эти ошибки». Перегенерация идёт через тот же конвейер проверок — без планировщика, тема уже задана старым вопросом, — с контекстом старого («НЕ ПОВТОРЯЙ, сделай другой на ту же тему»); новый вопрос связывается со старым — у вопросов есть родословная. State machine статусов плюс единый фильтр выдачи, вшитый во все пути показа и подсчёта готовности, гарантируют, что забракованный вопрос никогда не попадёт кандидату.

Качество тестируется как код: 12 регрессионных фикстур — по одной на каждую реальную проблему, найденную экспертом; 20 вручную размеченных фикстур утечки (10 «текущих», 10 чистых) — детектор утечек проверяется как модель, по precision/recall. Даже у промпта ремонта есть юнит-тест на бюджет длины: промпт — артефакт с контрактом.

Музей граблей

  1. «Промпт в описании профессии». Поле описания компетенции изначально никак не валидировалось — и пользователи закономерно стали писать туда собственные ТЗ: «Ты — senior разработчик…», «сгенерируй 5 вопросов», требования к количеству и типам вопросов прямо в тексте. Генератор послушно исполнял чужие инструкции, а проверяющие так же послушно браковали результат: на «грязных» описаниях доля прохождения факт-чекера падала до 3,1%, и система сжигала до 100 LLM-вызовов на один принятый вопрос. Из этого инцидента вырос препроцессор из раздела про подготовку входа; заодно эвристики выучили английские паттерны инъекций — до инцидента ловили только русские.
  2. «Хвост банка без валидации». При исчерпании бюджета деградация управляемая: отключается только дорогой симулятор кандидата — ниже уровня STANDARD, то есть факт-чекера, калибратора и структурных проверок, система не опускается никогда.
  3. «Дешёвая модель дешёвой модели рознь». Разброс pass rate валидации между economy-моделями — 0.71–0.84 против 0.28–0.29. Выбор дешёвой модели под валидацию — отдельное исследование, а не «возьмём что подешевле». Конкретные модели мы сознательно не называем — карта «модель → задача» часть ноу-хау, об этом в конце, — но сам факт четырёхкратного разброса переносим на любой стек. Бонус из той же серии: 83% completion-токенов одной дешёвой модели уходило в невидимый reasoning из-за унаследованной настройки.
  4. «Почти-JSON». Модели возвращают markdown-фенсы, reasoning-преамбулы, неэкранированные бэкслеши (привет, PHP-неймспейсы) и сырые переводы строк внутри строковых литералов. Наш парсер применяет пять стратегий по нарастающей плюс авторемонт, и покрыт примерно сорока юнит-тестами на реальные форматы. У разных вендоров свои сюрпризы: где-то нет structured output, где-то фиксированная температура, где-то служебные reasoning-токены незаметно съедают лимит ответа — без спец-обработки приходит пустой ответ.

Экономика качества и честные статусы

Качество стоит денег, и мы это не прячем: порядка десятка LLM-вызовов на принятый вопрос, банк собирается десятки минут (около получаса на банк из двух десятков вопросов), примерно половина первых генераций отклоняется факт-чекером и перегенерируется. Эта цифра не противоречит ~86% из раздела про ремонт утечки: половина — брак первого прохода, а ~86% — итоговый выход банка после ретраев и точечного ремонта. Есть три уровня качества — FAST (структурные проверки и дедуп), STANDARD (плюс факт-чекер и калибратор), FULL (плюс симулятор кандидата). Квота вопросов тарифа при этом списывается только за вопросы, прошедшие все проверки, — черновики и ретраи в неё не попадают.

Готовый банк вопросов по профессии: 180 вопросов, покрытие 9 из 9 компетенций, распределение по типам и сложности
Итог работы конвейера: банк на 180 вопросов с покрытием 9/9 компетенций и честной раскладкой по типам и сложности.

Прогресс стримится в UI по стадиям — «Проверка корректности…», «Ремонт утечки…», «Попытка 2 из 3: причина…» — вместо вечного спиннера, а по завершении приходит отчёт с раскладкой недостачи: не прошли проверку качества, дубликаты, темы исчерпаны, технические ошибки.

Экран готовности банка вопросов: фильтры «меньше 5 вопросов», «пробелы по компетенциям», «без вопросов» — все статусы на виду

Статусы готовности не прячутся: если по компетенции пробел — это видно фильтром, а не выясняется на оценке. Сравнение на идентичных описаниях компетенций: старый пайплайн — 59 вопросов и ноль проверок, почти все — с утёкшим в тест доменом компании, а 14 из 19 Python-вопросов — про одну подтему; новый — 5–7 уникальных подтем на компетенцию и ноль дубликатов.

Что мы вам не рассказали

Честно: тексты промптов, численные пороги, формулы агрегации и карту «модель → задача» мы сознательно не публикуем. Это и есть продукт, а публикация точных эвристик упрощает их обход. Зато принципы переносимы:

  1. Покрытие планируется заранее и защищается критиком разнообразия, а не надеется на случайность.
  2. Генератор никогда не проверяет сам себя: независимые модели других вендоров, до шести осей проверки, включая адверсариальные.
  3. Сложность оценивается вслепую.
  4. Дубликаты ловятся семантически, с учётом когнитивных уровней.
  5. Брак не выбрасывается втупую: точечный ремонт, ретраи с памятью, смена темы.
  6. Система умеет остановиться и явно сообщить о неудаче.
  7. Каждый инцидент закреплён в коде инвариантом и регрессионной фикстурой.
  8. Вопросы могут расти из ваших собственных регламентов — с provenance до конкретного абзаца.

Если хотите потрогать — генерация банков вопросов по компетенциям и по вашим документам доступна на vibe.codenrock.com. Если хотите поспорить об архитектуре — велком в комментарии. Нам особенно интересно: а как бы вы валидировали сам валидатор? Мы пришли к размеченным фикстурам и precision/recall для детектора утечек — но подозреваем, что это не единственный путь. И если тема зайдёт, отдельных статей просят парсер «почти-JSON», регрессионное тестирование промптов и RAG-ингест документов.

Частые вопросы

Чем генерация тестовых вопросов в Codenrock Vibe отличается от ChatGPT?

ChatGPT выдаёт вопросы одним вызовом без проверок. В Codenrock Vibe вопрос проходит конвейер: планировщик покрытия, генератор, структурный валидатор, факт-чекер на моделях других вендоров, калибратор сложности и симулятор кандидата. Вопросы с ошибками автоматически перегенерируются.

Можно ли генерировать тестовые вопросы по внутренним документам компании?

Да. В базу знаний загружаются PDF, DOCX, XLSX, TXT или веб-страницы, документы привязываются к компетенциям, и вопросы генерируются по фрагментам этих документов. У каждого вопроса сохраняется ссылка на источник вплоть до конкретного абзаца регламента.

Как AI проверяет качество сгенерированных вопросов?

Каждый вопрос проверяет независимый AI-рецензент другого вендора: факты в вариантах ответа, корректность эталонных решений, соответствие заявленной сложности. Отдельный агент проходит вопрос как кандидат и ловит неоднозначности. Решение принять или отклонить принимает детерминированный код, а не модель.

Что происходит с вопросами, которые не прошли проверку качества?

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

Оставьте заявку — подберём решение под вашу задачу

Оценка, ИПР, обучение и развитие команд, поиск талантов, хакатоны и соревнования. Расскажите, что нужно, — предложим лучшее.

Медиа Codenrock