В какой-то момент генератор чек-листов перестал падать. Запрос завершался успешно, JSON разбирался, результат сохранялся. Формально дефект был исправлен.

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

Тут и выяснилось, что первоначальная ошибка была простой частью задачи. У LLM-функции оказалось два разных уровня надёжности. Сначала приложение должно получить и разобрать ответ модели. Затем — правильно понять роль каждого входного документа и не превратить весь доступный контекст в требования. Успешный HTTP-ответ подтверждает только первый уровень, да и то не полностью.

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

Первая поломка: строка, которая перестала быть строкой

Началось всё с простого симптома. Запрос на генерацию чек-листа с включённой LLM-обработкой возвращал 500. Без LLM работал детерминированный путь, а с ней запрос падал при разборе ответа.

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

Упрощённо проблемный участок выглядел так:

response = model_client.generate(prompt)
result = parse_json(response)

В функцию разбора приходил не str, а объект. Первая же строковая операция завершалась исключением, которое поднималось до API как внутренняя ошибка сервера.

Сама ошибка почти банальна. Интереснее то, почему она дошла до рабочего сценария. LLM-клиент и прикладной сервис по отдельности выглядели корректно. Клиент честно возвращал типизированный ответ, сервис честно разбирал JSON. Разрыв находился на их границе, где не было зафиксировано, кто именно отвечает за преобразование транспортного объекта в обычный текст.

Можно было принудительно преобразовать весь объект в строку и закрыть дефект. Я от этого варианта отказался. Строковое представление объекта не обязано совпадать с содержимым ответа. Сегодня оно вернёт текст, завтра — служебное представление с метаданными, и JSON-парсер снова начнёт зависеть от внутреннего устройства клиента.

Вместо этого между клиентом модели и JSON-парсером появился отдельный адаптер. Ниже не production-код, а схема контракта:

response = model_client.generate(prompt)
text = normalize_response(response)
result = parse_json(text)

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

Заодно изменилось поведение при невалидном ответе модели. Плохой JSON на необязательном этапе обработки не должен был уничтожать детерминированно собранный чек-лист. Такой исход стал фиксироваться как частичный результат: модель не помогла, но базовый результат сохранился. Без этого различия отказ дополнительного улучшателя легко принять либо за полный отказ функции, либо, наоборот, за полностью успешную LLM-обработку.

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

Вторая поломка: два документа без ролей

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

Оба документа полезны, но полезны по-разному. Требование вроде «операция с недопустимыми входными данными должна быть отклонена» естественно превращается в проверку. Формулировка «неизвестно, как система поведёт себя при повторной обработке» — это не готовое требование, а сигнал риска или открытый вопрос. Если обработать их одинаково, во втором случае получится тест, ожидаемый результат которого система вынуждена придумать сама.

Именно это и происходило. Пайплайн воспринимал оба текста как равноправные источники пунктов чек-листа. Каждая похожая на техническое утверждение строка получала шанс превратиться в проверку. Исследовательский документ содержал много профессиональной лексики — «приёмка», «покрытие», «traceability», «runtime», — поэтому поверхностный фильтр считал его ещё одним техническим заданием.

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

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

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

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

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

Почему правильный JSON ничего не доказал

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

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

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

Так проверка LLM-функции перестала быть проверкой одного ответа. Появились два независимых контракта. Транспортный контракт определяет, как получить текст от конкретного клиента и что делать при пустом, повреждённом или необязательном ответе. Семантический контракт определяет, какие сведения модель вправе превратить в результат, какие остаются подсказками для анализа, а какие должны быть помечены как недостаточные для вывода.

Эти контракты лучше не смешивать. Если клиент вернул объект вместо строки, бесполезно менять prompt. Если исследование превратилось в требования, бессмысленно совершенствовать JSON-парсер. У ошибок похожий внешний признак — плохой результат генерации, — но исправляются они в разных слоях.

Что изменилось после разделения источников

Повторная генерация на той же паре документов дала 152 строки вместо 271. Само уменьшение почти вдвое не является доказательством качества: короткий чек-лист тоже может быть плохим. Важнее, какие строки исчезли.

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

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

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

Как я теперь проверяю подобные пайплайны

После этого случая я перестал считать проверку 200 + valid JSON достаточным smoke-тестом для LLM-функции. Такой тест нужен, но он находится в начале, а не в конце проверки.

Сначала я смотрю на фактический тип ответа клиента. Особенно если библиотека или адаптер недавно обновлялись. Затем отдельно проверяю нормализацию в текст и поведение на пустом либо повреждённом ответе. Если LLM-этап необязательный, основной результат не должен исчезать из-за ошибки улучшателя.

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

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

Итог

В этом расследовании было два последовательно «успешных» исправления. Сначала приложение научилось принимать новый тип ответа LLM-клиента и перестало возвращать 500. Затем генератор научился различать требования и вспомогательное исследование, после чего результат сократился с 271 до 152 строк и перестал выдавать значительную часть аналитических заметок за готовые проверки.

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

Если проверять только HTTP-статус и JSON Schema, можно получить полностью зелёный тест на функцию, которой всё ещё нельзя доверять. В прикладном AI корректная форма ответа — это начало проверки. Смысл и происхождение результата приходится доказывать отдельно.