После проверки LLM: где конвейер теряет полезные результаты

В первой статье я разбирал, как ограничить полномочия LLM-судьи. Если ответ можно проверить обычным кодом, последнее слово остаётся за кодом. Если уверенного решения нет, случай уходит на ручную проверку.

Но отправить запись на ручную проверку ещё не значит, что человек её увидит. А проверить все полученные ответы ещё не значит, что модель ответила на все входы.

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

С чего начался разбор

Бот собирает посты с вакансиями, проверяет их по профилю кандидата и показывает подходящие. В какой-то момент он стал присылать две вакансии в неделю вместо десятков. Итоговые проверки проходили, но пользы от бота было мало.

В одном из прогонов оказалось 543 поста. Из них 530 получили skip, то есть «не показывать». Из этих отказов 196 пришлись на один фильтр по роли.

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

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

1. Часть ответов не дошла до обработки, а пачка считалась готовой

Посты уходят в модель пачками. Для каждого поста она должна вернуть разметку. Затем код проверяет ответ и выбирает маршрут: показать вакансию, отклонить или отправить на ручную проверку.

Вот упрощённый фрагмент обработки ответа:

missing = expected_ids - seen_ids
if missing:
    errors.append(f"нет результата для: {sorted(missing)}")
if errors:
    print(f"[chunk {idx}] validation errors: {errors}")
ok = True      # даже если есть ошибки
break

Код замечал недостающие записи, печатал их в лог и всё равно выставлял ok = True. Пачка считалась обработанной, повтор не выполнялся.

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

Сводка по маршрутам тоже не помогала. Она считала только дошедшие до неё результаты. Если общее число взять из той же сводки, сумма всегда сойдётся.

Что поменял

Число входов теперь берётся до обработки, независимо от числа ответов. Для всего прогона проверяется равенство:

входы = обработанные + отклонённые + технические ошибки + оговорённые исключения

Каждая запись должна учитываться только в одной категории. Исключения задаются заранее, с названием и количеством. Если числа не сходятся или указана неизвестная причина исключения, прогон завершается с ошибкой.

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

Отдельно проверяется число технических ошибок. Если все записи учтены, но модель на каждой упала по таймауту, задача всё равно не выполнена. У меня для judge_error и timeout допустимое число ошибок равно нулю. Эти условия вместе я называю контрактом прогона.

Как проверка сработала

Через несколько дней я расширял профиль отбора: сначала поменял правила в коде, затем промпт. Потребовалось заново разметить 188 записей.

Разметку получили 168. Ещё 20 не дошли до маршрута. Проверка остановила прогон:

FAIL coverage: прогон не выполнил агрегатный контракт
  reason 'missing_record' on 20 case(s), contract allows at most 0

Раньше у этих двадцати записей уже были маршруты. После переразметки результатов не стало. Без новой проверки это выглядело бы как успешное обновление.

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

2. Валидатор выбрасывал вакансию из-за вспомогательного поля

В ответах встречались значения не из того списка. Например, поле с вариантами yes, no и unknown получало pass. В других случаях модель возвращала строку там, где ожидался объект.

Ошибка повторилась и при неизменных коде и промпте. В одном прогоне из 115 записей без результата остались четыре. В другом, из 764, неверное значение встретилось в 11 записях. Пять спас повтор запроса, шесть остались без результата и после него. Ещё одну запись модель не вернула по другой причине.

В промпте уже было прямо написано:

remote_from_russia и relocation принимают ТОЛЬКО yes/no/unknown - не pass/fail

Модель всё равно возвращала pass. Ещё раз написать запрет было бы недостаточно.

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

Что поменял

Для вспомогательных полей я заменил отбраковку на значение по умолчанию. Например, неверное значение в поле yes/no/unknown становится unknown, а не no: отсутствие уверенности не должно превращаться в отказ.

При этом pass не переводится автоматически в yes. Код не должен додумывать, что модель имела в виду. Исходное значение сохраняется рядом, а замена отражается в диагностике.

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

Почему модель иногда путает значения, я пока не установил. На повторной обработке семи записей она сама вернула правильный ответ, замены не понадобились. Возможно, дело в размере пачки или длине ответа, но отдельного эксперимента на это я не проводил.

Почему отчёт говорил, что модель ничего не вернула

Здесь нашлась ещё одна ошибка. Невалидная запись сначала получала причину invalid_record, но в список успешно обработанных не попадала. Следующий шаг считал отсутствующим всё, чего нет в этом списке, и перезаписывал причину на missing_record.

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

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

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

3. Вакансия дошла до ручной очереди, но не до человека

Теперь вернёмся к 530 отказам. Проверка количества здесь ничего не обнаружит: skip является учтённым результатом. Она не отвечает на вопрос, правильно ли вакансию отклонили.

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

Из 33 размеченных отказов пользователя 31 был связан с ролью. Но в 30 случаях из этой группы модель сама ответила unknown. Лишь в одном случае она поставила pass, а человек с этим не согласился.

Эта разметка помогала разбирать показанные карточки. О вакансиях, которых пользователь не увидел, она ничего не говорила. Для этого пришлось отдельно посмотреть отклонённые записи и ручную очередь.

В очереди нашлись 22 вакансии, подходящие по профилю. Они получили unknown и почти не попадали в показ: размер выдачи был ограничен, а порядок задавала уверенность модели в собственной разметке. Это не то же самое, что соответствие вакансии профилю кандидата.

Что поменял

После изменения сортировки на проверенном наборе из 169 карточек в лимит показа попали все 22 подходящие вакансии. До изменения попадала одна.

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

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

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

Что можно проверить у себя

Для начала не обязательно менять модель или ставить библиотеку. Я бы проверил три ситуации на небольшой тестовой пачке:

  1. Модель вернула не все записи. Видно ли, каких именно не хватает? Завершается ли неполная обработка с ошибкой, или лог остаётся единственным следом?

  2. Модель ошиблась во вспомогательном поле. Действительно ли из-за него нужно отклонять всю запись? Отличает ли отчёт такой отказ от отсутствующего ответа?

  3. Запись ушла на ручную проверку. Попадает ли она в доступную человеку выдачу? Что остаётся за лимитом и есть ли среди этого полезные результаты?

Отдельно стоит проверить дубль вместо пропущенной записи. Сумма в таком тесте может сойтись, поэтому нужен учёт по идентификаторам.

Если в тестовом наборе ожидаются обращения к определённой ветке, проверяйте и то, что она действительно запускалась. Но не переносите такое ожидание на любую рабочую пачку: среди десяти новых постов может честно не оказаться ни одной подходящей вакансии.

Код и границы результата

Общую проверку количества исходов, маршрутов и причин ошибок я вынес в grounded-judge-gate:

pip install grounded-judge-gate==0.3.0

Исходники и тесты этой версии закреплены тегом v0.3.0. Обработка полей вакансий, сортировка и аудит очереди остались в самом конвейере. Библиотека не определяет, какая вакансия нужна человеку.

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

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

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

Комментарии (0)