Как убедиться, что ваша LLM не выдаст полную чепуху в самый неподходящий момент? Как проверить, что она действительно понимает контекст, а не просто генерирует красивые, но бессмысленные фразы? И самое главное — как сделать это эффективно, не тратя недели на ручную проверку тысяч ответов?
Сегодня технологии развиваются с огромной скоростью. Классические методы разработки уходят на второй план. Им на смену приходят большие языковые модели (LLM) и их приложения.
LLM уже меняют многие сферы жизни: медицину, образование, бизнес. Эти модели становятся мощнее и умнее с каждым днём.
Но перед нами встают новые задачи. Как правильно разрабатывать такие системы? Как тестировать их, чтобы они работали стабильно? Чтобы справиться с этим, нужны новые подходы и глубокое понимание LLM.
Давайте разберёмся, как эти модели работают и что ждёт нас в будущем.
Есть три главных столпа тестирования LLM:
Пользовательский фидбэк: Самый очевидный, но запоздалый способ понять, работает ли ваше приложение. Ведь фидбэк возможен только после релиза. А если ошибки спрятаны до продакшна?
Ручное тестирование: Трудозатратное занятие, способное исчерпать даже самую энергичную команду. О плюсах и минусах применения на практике вы, вероятно, уже знаете: субъективность и, увы, тоже ошибки.
Автоматическое тестирование: Спасительная гавань для всех разработчиков LLM. Однако есть нюанс — оно все еще развивается, как и сами LLM, и всегда требует свежих подходов.
Для того, чтобы протестировать LLM автоматически, можно использовать разные инструменты:
Классические тесты
Готовые метрики
Кастомные метрики
Когда мы только окунулись в разработку приложений с LLM, мы начали с изучения Pydantic классов в Python. Да, Pydantic — это как волшебная таблетка для превращения хаоса ответов модели в нечто более предсказуемое и детерминированное. С их помощью можно настроить весь набор атрибутов, которые мы ожидаем увидеть.
Вот что можно настроить для каждого атрибута:
Название
Тип
Обязательность
Описание: как краткая заметка — зачем этот атрибут нужен.
Значение по умолчанию: если атрибут не обязателен
Пример класса:
class Appointment(BaseModel):
time: str = Field(..., description="Date and time of meeting in format YYYY-MM-DDTHH:MM:SS")
duration: int = Field(description="Duration of the meeting in minutes", default=60)
title: str = Field(..., description="Meeting title in format: Interview [Name Surname] (of candidate)")
description: str = Field(..., description=f"Link to the meeting in format: meeting/[name_surname] (of candidate)")
Что мы здесь видим? Все, что внутри этого класса, — это как указания модели о том, на что она должна целиться. Более того, можно подключить валидатор, который опять же проверит, соответствует ли ответ модели всем требованиям по ключам и типам.
Теперь о тестах. Чтобы обычные assert-тесты могли подтвердить, что ответ модели верен, вам нужно заранее знать, какой именно ответ ожидается. Например:
Представьте, вы хотите, чтобы модель разобралась, сколько животных у бабушки в сказке про трех гусей. Тогда вы опишете ответ модели вот так:
class ModelOutput(BaseModel):
pets: int = Field(..., description='Number of pets')
Предположим, вы даете модели ввод 'Жили у бабуси три веселых гуся', и тогда ваш assert будет выглядеть так:
assert actual_output == ModelOutput(pets=3)
Звучит просто, да? Так можно оценивать не только числа, но и строки, если у вас есть представление, каким должен быть шаблон. Регулярные выражения — отличный способ справляться с проверками конкретных шаблонов.
Этот тип тестирования — настоящая выручалочка для начала работы с ЛЛМ. Но не забывайте: успех тут зависит от вашего мастерства в написании четких и подробных инструкций для ваших задач.
Хотя тестирование LLM — это еще, по сути, совсем новая стезя, уже есть целый арсенал текстовых метрик, которые помогают разобраться, насколько качественно ваша модель справляется с задачами. Почему именно текстовые метрики? Да потому что текст — основа ответов LLM. Вот самые основные метрики, с которыми мы имели дело:
Саммаризация
Релевантность ответа
Правдивость
Галлюцинация
На самом деле, метрик для оценки текста огромное множество, и при желании можно подобрать что-то под любую задачу, используя готовые ресурсы. Но вот загвоздка: как внедрить их в тесты? Для этого мы нашли два основных фреймворка:
DeepEval
LangSmith Testing
Эти фреймворки — ваши верные помощники в тестировании LLM. Каждый из них имеет свои особенности и подходит для разных нужд.
LangChain и его огромное сообщество — пожалуй, самый популярный фреймворк для создания LLM приложений. Он стал для нашей команды основным инструментом для создания языковых цепочек, поэтому мы и начали тестирование с него. Но, надо признать, что все оказалось не таким легким и удобным, как мы надеялись.
Сначала расскажем о плюсах, которые мы обнаружили:
Возможность создания датасетов под конкретные задачи
Простота написания экспериментов для конкретных датасетов
Все эксперименты и датасеты сохраняются, что позволяет отслеживать динамику изменений
Если вы регистрируете свои цепочки и агенты в LangSmith, можно привязать датасет и эксперимент именно к конкретной цепочке
Возможность версионирования датасетов - позволяет тестировать все сохраненные версии вашего приложения
В общем и целом, это неплохой инструмент для тестов, если ваши задачи не слишком специфичны. Однако, мы столкнулись с рядом трудностей, которые решили не обходить стороной.
Первая проблема — отсутствие возможности управлять датасетами из кода. Вы можете создавать датасеты и добавлять туда примеры, но вот удалять их или конкретные элементы автоматически не получится. Это значит, что если ваша команда работает с нескольких аккаунтов, каждому члену нужно вручную создавать или удалять датасеты перед запуском эксперимента. Если датасет уже существует, все элементы будут создаваться по-новой при запуске эксперимента, и статистика окажется искаженной.
Следующая проблема - не поддержка асинхронности. При создании эксперимента:
evaluators = [
LangChainStringEvaluator("cot_qa")
]
results = evaluate(
chain.invoke,
data=data,
evaluators=evaluators,
experiment_prefix=experiment_prefix,
)
Мы обнаружили, что в методе evaluate
нужно явным образом вызвать цепочку через invoke
. А что если ваша цепочка асинхронная? Менять асинхронность на синхронность ради тестов — такое себе удовольствие. Нам пришлось написать специальный враппер, чтобы обернуть ainvoke
в синхронную функцию. Но и тут не обошлось без приключений. LangChainStringEvaluator
создает свой event loop, который нужно перехватить, чтобы запустить цепочку.
Вот как нам в итоге удалось внедрить нашу асинхронную цепочку:
nest_asyncio.apply()
event_loop = asyncio.get_event_loop()
def sync_invoke_wrapper(inputs):
try:
result = event_loop.run_until_complete(chain.ainvoke(inputs))
return result
except Exception as error:
raise error
В LangSmith набор метрик достаточно ограничен. Полный список, конечно, можно найти в документации, но, по нашему мнению, этих метрик недостаточно, чтобы тестировать приложения с бизнесовой точки зрения. Можно протестировать качество текста, но это больше похоже на лингвистическую оценку и не более.
Если вам хватает представленных метрик, то этот метод вполне рабочий. Однако наша команда решила пока не использовать этот продукт на данном этапе его разработки.
Хотя DeepEval и не так популярен, как LangSmith, именно он стал нашим выбором в тестировании LLM.
Вот какие плюсы мы нашли в этом фреймворке:
Поддержка датасетов.
DeepEval, как и LangSmith, позволяет создавать датасеты. Но самое важное — в DeepEval вы можете полностью управлять ими прямо из кода. Все проблемы, о которых мы говорили в контексте LangSmith, здесь просто исчезают.
Обилие готовых метрик.
В DeepEval есть множество готовых метрик, что выгодно его отличает. В LangSmith метрики в основном оценивают текст с лингвистической точки зрения, что не всегда подходит для бизнес-задач. Метрики DeepEval больше ориентированы на оценку полезности ответа в конкретных кейсах.
Правда, они работают корректно только в классических сценариях. Например, метрика для саммаризации будет адекватно оценивать только традиционные краткие пересказы. Если в саммари добавится анализ или что-то специфическое, метрика может вести себя нестабильно или вовсе не сработать.
Простота написания тест-кейсов.
В DeepEval легко писать тест-кейсы, поскольку их можно просто позаимствовать из документации для конкретной метрики. Однако - обращайте внимание, какие параметры она принимает. Чтобы понять, что именно передавать, стоит изучить описание, как метрика работает. Для достижения максимально точного результата нужно предоставить тест-кейсу полный контекст. Если ваша модель отвечает на вопрос, учитывая информацию откуда-либо кроме юзер промпта, этот информацию тоже обязательно передать в тест-кейс. Иначе ответ будет оценен как содержащий лишнюю информацию.
Если при использовании метрик DeepEval вы получаете неожиданно низкие оценки, попробуйте следующее:
Внимательно изучите ризон, которую дает метрика вместе с оценкой.
Если ризон указывает на нехватку контекста, попробуйте изменить или добавить параметры.
Если контекст полный, но оценка все еще не оправдывает ожиданий, попробуйте использовать кастомную метрику GEval.
(мы все погуглили за вас)
Фича | LangSmith | DeepEval |
---|---|---|
Мониторинг и отладка в продакшене | Поддерживает комплексно мониторинг и визуализацию данных в продакшене для оценки LLM. Предоставляет возможность отслеживать логи и добавлять тестовые данные | Не фокусируется на мониторинге продакшена, но интегрируется с существующими фреймворками для локального юнит-тестирования и анализа моделей |
Создание и хранение датасетов | Поддерживает создание и хранение тестовых датасетов, что позволяет добавлять примеры из реального использования приложения | Работает с тестовыми датасетами для юнит-тестирования и гиперпараметрической оптимизации, но акцент больше на проверках конкретных случаев, чем на хранении |
Метрики для оценки | Интеграция с Ragas для метрик верности, релевантности ответа и контекста. Помогает объяснять результаты этих метрик и делает их воспроизводимыми | Предоставляет набор метрик для оценки LLM-ответов, но с акцентом на гибкость и интеграцию с популярными ML-фреймворками для точечной оценки |
Интеграция с другими системами | Хорошо интегрируется с LangChain для отладки и мониторинга цепочек, включая поддержку мультимодальных задач и сложных QA-пайплайнов | Открытая интеграция с популярными LLM и ML-фреймворками, позволяющая гибко подключать различные источники для проведения оценок и тестов |
Автоматическое тестирование | Фокусируется на непрерывном автоматическом тестировании в продакшене с помощью платформы | Поддерживает автоматическое тестирование моделей и их конфигураций, но больше заточен на оффлайн-проверки и оптимизацию в разработке |
Выбор за вами!
В процессе работы с LLM мы поняли, что тестирование моделей с точки зрения бизнес-ценности - не такое уж и очевидное дело. Классические тесты и готовые метрики не всегда подходят.
Вот, например, простой сценарий: проверка языка ответа модели. Если модели подается системный промпт и контекст на английском, а вопрос пользователя может быть на любом языке, есть вероятность, что модель ответит на английском, даже при явной инструкции "ответить на языке пользователя". Визуально это проверить легко, но что делать, если запросов тысячи? Здесь на помощь приходят кастомные метрики GEval.
GEval — это метрика из набора DeepEval.
Она оценивает ответ модели по заданным вами критериям, выставляя скор от 0 до 1. В отличие от готовых метрик, где критерии уже прописаны, здесь вы можете задавать свою логику оценки. Хочется проверить язык ответа? Или количество предложений? А может, стиль и тон общения? GEval даст вам такую возможность.
Чтобы создать свою метрику, реализуйте её через класс GEval:
GEval(
name="Имя вашей метрики",
criteria="Критерии для оценки"
evaluation_steps=[
"Степ 1",
"Степ 2",
"Степ 3"
],
verbose_mode=True,
threshold=0.7,
evaluation_params=[
LLMTestCaseParams.INPUT,
LLMTestCaseParams.ACTUAL_OUTPUT,
],
)
Можно использовать или критерий, или шаги (степы):
Критерий: модель сама распишет шаги оценки, исходя из критерия.
Шаги: вы прописываете последовательные действия, которые модель выполнит для выставления скора.
Когда мы только начали использовать GEval, мы использовали степы. Нам казалось, что это более чистый и последовательный подход. НО! Мы были не совсем правы. Изучив подробно официальную статью по GEval, мы поняли, что степы написать не так то просто, как кажется. Степы - это не просто последовательность действий, а четкая инструкция для модели, заставляющая ее действовать в рамках Chain of Thought.
Поэтому вот как мы рекомендуем работать с GEval:
Начните с критерия.
Посмотрите, как модель интерпретирует ваши требования в логах, и при необходимости корректируйте критерий.
Когда вы увидете степы, идеально подходящие под ваш кейс, просто скопируйте их и вставьте в evaluation_steps.
Такой подход даст вам наибольшую стабильность метрики, так как степы не будут генерироваться но-новому каждый запуск.
Определите границы метрики:
Что именно должно оцениваться? Не смешивайте проверку нескольких параметров в одной метрике.
Определите идеальный ответ:
Перечислите по пунктам, какой ответ вы ждете.
Например, для рецепта: список продуктов, подготовка, шаги приготовления, варианты подачи.
Пропишите критерий.
Критерий должен описывать, каким должен быть эталонный ответ. Например:
"Ответ написан на русском языке. Ответ связанный, последовательный, не содержит лишних предложений."
Каждая метрика обязательно принимает input и output, чтобы установить связь вопрос-ответ. Дополнительные параметры могут включать контекст, ожидаемый ответ и так далее, исходя из вашего кейса. Это нужно для полного контекста оценки.
Threshold — это минимальный скор метрики, который считается успешным при прохождении теста. Проще говоря, это пороговое значение от 0 до 1, где ближе к 1 — значит лучше ответ модели соответствует заданным критериям.
Честно говоря, мы сами все еще разбираемся с этими значениями. Все дело в том, что одна нестабильная модель оценивает другую нестабильную модель. Так что, даже если ваш ответ идеален, не стоит рассчитывать на то, что он всегда будет получать заветную единицу.
Как же мы поступаем с этим threshold'ом?
Модель, кроме скора, всегда дает пояснение (ризон), где перечислены плюсы и минусы ответа. Если в ризоне нет указаний на недостатки, то и скор может считаться положительным.
Вот как мы определяем threshold:
Создаем заведомо плохой ответ
Пропускаем его через метрику и анализируем скор и ризон
Постепенно улучшаем плохой ответ, следя за повышением скора
Как только ответ достигает минимально приемлемого уровня для нашего кейса, фиксируем его скор
Этот скор и станет вашим ориентировочным threshold'ом.
Нам пока не удалось выработать четкое понимание того, как именно планировалось работать с threshold по задумке создателей фреймворка. Возможно, эта информация появится в документации со временем. Пока же, мы идем путем проб и ошибок и учимся на собственном опыте.
Выбор подхода — это, пожалуй, самая сложная и важная задача при тестировании LLM. Вот что мы обычно делаем, чтобы определиться:
Пересматриваем accepts критерии:
Возвращаемся к основным критериям, разработанным на этапе планирования продукта или истории.
Выписываем конкретные требования:
Определяем, каким стандартам должен соответствовать ответ модели, цепочки или агента.
Прописываем способы проверки для каждого требования:
Например, если требования к ответу:
json с ключами name, age, hobbies
имя должно быть в им. падеже с заглавной буквы
возраст должен быть целым положительным числом
хобби должны быть листом со строками
все пункты должны быть правдивыми и соответствующими поданному на вход тексту
хобби должны включать все перечисленные к тексте
содержание ключей должно быть на языке запроса
То способы тестирования каждого пункта будут:
json с ключами name, age, hobbies - простой тест на соответствие объектов
имя должно быть в им. падеже с заглавной буквы - кастомная метрика
возраст должен быть целым положительным числом - простой тест на тип
хобби должны быть листом со строками - простой тест на тип
все пункты должны быть правдивыми и соответствующими поданному на вход тексту - галлюцинация
хобби должны включать все перечисленные к тексте - кастомная метрика
содержание ключей должно быть на языке запроса - кастомная метрика
После этого мы приступаем к реализации всех необходимых тестов, следуя правилам, описанным в предыдущих разделах.
Мы хотим разработать цепочку, которая будет:
Принимать на вход детскую сказку
Анализировать сказу
Возвращать нам имя главного персонажа и его описание
И так, начнем с критериев, которые мы ожидаем от ответа:
В ответе есть описание характера персонажа
Описание характера пишется моделью на основе анализа сказки, оно не обязательно есть в самой сказке прямым текстом
В ответе есть имя персонажа
Имя персонажа пишется с большой буквы в им. падеже
Мы написали какой-то промпт модели и составили цепочку chain. Сначала пишем класс для формата ответа модели:
class Character(BaseModel):
name: str = Field(..., description="Character's name in nom. case and capitalized")
description: str = Field(..., description="Character description")
Теперь решим, какими методами будем оценивать каждый критерий ответа:
классический тест на тип и не пустое значение
кастомная метрика
классический тест на тип и не пустое значение
классический тест на соответствие значений
Напишем тесты для каждого критерия:
fairy_tale = "There is a goose Ivan. Ivan is a very communicative goose"
#Check that class has NAME and DESCRIPTION present and not empty
def test_output():
result: Character = chain.invoke({'fairytale': fairy_tale})
assert result.name
assert result.description
#Check that NAME and DESCRIPTION are strings
def test_output_types():
result: Character = chain.invoke({'fairytale': fairy_tale})
assert isinstance(result.name, str)
assert isinstance(result.description, str)
#Check that character description is relevant with fairy tale
def test_character_description():
result: Character = chain.invoke({'fairytale': fairy_tale})
description = result.description
metric = GEval(
name="Character description corectness",
criteria="Check that the character description is correct and matches the text of the fairy tale",
verbose_mode=True,
threshold=0.7,
evaluation_params=[
LLMTestCaseParams.INPUT,
LLMTestCaseParams.ACTUAL_OUTPUT,
],
)
assert_test(
test_case=LLMTestCase(
input=f"Fairy tail: {fairy_tale}",
actual_output=description
),
metrics=[metric]
)
#Check that name is correct and in nom. case and capitalized
def test_name():
result: Character = chain.invoke({'fairytale': fairy_tale})
name = result.name
assert name == 'Ivan'
Threshold тут выставлен 0.7 - для примера. В реальных тестах используйте логику выставления, о которой мы рассказывали, или же пробуйте свою!
Вот и все! Теперь ваши приложения надежно защищены тестами, которые можно обновлять и добавлять, и при этом - всегда следить за их зеленым цветом!
Эта статья была подготовлена под руководством @pletinsky. Выражаем особую благодарность за помощь в создании материала.
English version of this article is available here.
Официальные документации фреймворков: LangSmith testing, DeepEval
Документация GEval: GEval
Статьи про тестирование: Testing of LLM models, Evaluating Outputs , How to Test LLM Applications