Как стать автором
Обновить
510.73
OTUS
Цифровые навыки от ведущих экспертов

Test Driven Development: сначала тесты, потом реализация

Уровень сложностиСредний
Время на прочтение14 мин
Количество просмотров593

Test Driven Development в простом понимании означает написание тестов перед написанием кода, но каждый, кто практиковал его по‑настоящему, знает, что это понятие гораздо шире.

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

В этой статье, написанной на основе публикации Rogério Chaves «The complete guide for TDD with LLMs» мы рассмотрим использование больших языковых моделей (LLM) для Test Driven Development.

Хорошо и плохо сразу

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

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

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

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

Выполнение TDD для LLM с помощью pytest

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

import pytest

from app import recipe_bot
@pytest.mark.asyncio

async def test_fits_a_tweet(entry, model):
    recipe = await recipe_bot.generate_tweet(
      input="Generate me a recipe for a quick breakfast with bacon",
      model="gpt-3.5-turbo"
    )

    assert len(recipe) <= 140

Мы используем pytest для запуска и выполнения тестов, а также pytest‑asyncio для асинхронного запуска тестов, что будет важно чуть позже.

Сейчас наш тест достаточно прост: мы проверяем длину переданных данных. Теперь, согласно правилу TDD, мы запускаем тест (без реализации), видим, что он провалился (если он провалился, значит, что‑то не так!), и переходим к реализации:

import litellm
from litellm import ModelResponse

async def generate_tweet(input: str, model: str = "gpt-3.5-turbo") -> str:
    response: ModelResponse = await litellm.acompletion(
        model=model,
        messages=[
            {
                "role": "system",
                "content": "You are a recipe tweet generator, generate recipes using a max of 140 characters.",
            },
            {"role": "user", "content": input},
        ],
        temperature=0.0,
    )  # type: ignore

    return response.choices[0].message.content  # type: ignore
 

Мы используем библиотеку litellm, как замену для OpenAI, которая позволяет нам использовать множество различных LLM, просто меняя аргумент модели. Получается простая, понятная подсказка и все должно работать. Мы снова запускаем тест:

> pytest

============================================================================ FAILURES =============================================================================
________________________________________________________________________ test_fits_a_tweet ________________________________________________________________________

    @pytest.mark.asyncio
    async def test_fits_a_tweet():
        recipe = await recipe_bot.generate_tweet(
            input="Generate me a recipe for a quick breakfast with bacon",
            model="gpt-3.5-turbo",
        )

>       assert len(recipe) <= 140
E       AssertionError: assert 144 <= 140
E        +  where 144 = len('Crispy Bacon Breakfast Tacos: Cook bacon until crispy, scramble eggs, fill tortillas with eggs, bacon, cheese, and salsa. Enjoy! #breakfasttacos')

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

- "content": "You are a recipe tweet generator, generate recipes using a max of 140 characters.",

+ "content": "You are a recipe tweet generator, generate recipes using a max of 140 characters, just the ingredients, no yapping.",

Выполним проверку:

> pytest

tests/test_recipe_bot.py .                                                                                                                                  [100%]

======================================================================== 1 passed in 1.77s ========================================================================

Тест выполнен успешно, но всегда ли он будет работать верно? Здесь есть несколько проблем, о которых мы поговорим далее.

Проблема №1: непредсказуемые входные данные

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

import pandas as pd

entries = pd.DataFrame(
    {
        "input": [
            "Generate me a recipe for a quick breakfast with bacon",
            "Generate me a recipe for a lunch using lentils",
            "Generate me a recipe for a vegetarian dessert",
            "Generate me a super complicated recipe for a dinner with a lot of ingredients",
        ],
    }
)

 

@pytest.mark.asyncio
@pytest.mark.parametrize("entry", entries.itertuples())
async def test_fits_a_tweet(entry):
    recipe = await recipe_bot.generate_tweet(
        input=entry.input,
        model="gpt-3.5-turbo",
    )

    assert len(recipe) <= 140

Теперь у нас есть не один пример, а четыре, и заметьте, что в последнем из них пользователь пытается быть умником, давайте посмотрим, насколько наш бот устойчив к этому!

> pytest

tests/test_recipe_bot.py .                                                                                                                                  [100%]

======================================================================== 4 passed in 7.10s ========================================================================

Да, это так! Отлично, это дает нам больше уверенности. Заметьте, что в выводе написано 4 теста пройдены, то есть при использовании parametrize с примерами входов один тест превратился в четыре.

Примечательно, что при повторном запуске мы можем получить другой результат:

============================================================================ FAILURES =============================================================================
FAILED tests/test_recipe_bot.py::test_fits_a_tweet[entry3-gpt-3.5-turbo] - AssertionError: assert 141 <= 140
============================================================= 1 failed, 3 passed, 1 warning in 7.12s =============================================================

Почти! На один лишний символ! Но как так получилось? Тесты только что прошли в предыдущем запуске, но не все так просто, точнее стабильно.

Проблема №2: зыбкость

Это большая проблема для LLM, дряблость (различный результат) присуща LLM в гораздо большей степени, чем традиционному программному обеспечению. К счастью, есть еще одна библиотека, которая может нам помочь, и ее название тоже вроде как очевидно — Flaky! Flaky позволяет нам пометить тест, который будет повторен несколько раз, пока он не сработает:

  @pytest.mark.asyncio
+ @pytest.mark.flaky(max_runs=3)
  @pytest.mark.parametrize("entry", entries.itertuples())

  async def test_fits_a_tweet(entry):
      recipe = await recipe_bot.generate_tweet(
          input=entry.input,
          model="gpt-3.5-turbo",
      )

      assert len(recipe) <= 140 

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

> pytest

tests/test_recipe_bot.py ....                                                                                                                                [100%]
================================================================== 4 passed, 1 warning in 6.80s ===================================================================

Отлично! Все прошло! Это значит, что еще пара попыток — и наш бот сможет успешно уложиться в 140 символов. Но что мы на самом деле хотим сказать? Иногда тест проваливается, а иногда работает (по крайней мере, 33% случаев), но, скорее всего, в продуктиве вы не будете повторять попытки много раз, и вы даже не будете знать, неправильно ли то, что вы сгенерировали, или нет, чтобы вы могли повторить попытку, поэтому наш тест не дает нам такой большой уверенности (только 33%).

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

 @pytest.mark.asyncio
- @pytest.mark.flaky(max_runs=3)
+ @pytest.mark.flaky(max_runs=3, min_passes=2)

  @pytest.mark.parametrize("entry", entries.itertuples())
  async def test_fits_a_tweet(entry):
      recipe = await recipe_bot.generate_tweet(
          input=entry.input,
          model="gpt-3.5-turbo",
      )

      assert len(recipe) <= 140

Теперь вы говорите, что он может попробовать 3 раза, но должен пройти хотя бы в 2 из них, ~67% времени. Но означает ли это, что результаты ваших юнит‑тестов теперь говорят, что это нормально, если что‑то иногда не работает? Это может заставить многих людей чувствовать себя неловко, однако мы находимся в вероятностном мире, помните, что, если вы определили, что по вашим спецификациям, процент прохождения ваших тестов уже намного лучше, чем тот, с которого мы начинали, находясь в полной неизвестности, теперь, по крайней мере, это просто вопрос увеличения этих цифр, по мере того как вы итерируетесь и улучшаете свое решение.

А что, если попробовать другую модель? Более умную, например, gpt-4o? Если она работает на gpt-3.5-turbo, то это должно быть просто, верно? А как насчет модели с открытым исходным кодом, например llama3? В конце концов, подсказки ведь можно переносить? Нет?

Проблема № 3: Переносимость подсказок

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

from itertools import product

models = ["gpt-3.5-turbo", "gpt-4o", "groq/llama3-70b-8192"]

@pytest.mark.asyncio
@pytest.mark.flaky(max_runs=3, min_passes=2)
@pytest.mark.parametrize("entry, model", product(entries.itertuples(), models))

async def test_fits_a_tweet(entry, model):
    recipe = await recipe_bot.generate_tweet(
        input=entry.input,
        model=model,
    )

    assert len(recipe) <= 140

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

> pytest

tests/test_recipe_bot.py ........F.FF                                                                                                                       [100%]

============================================================================ FAILURES =============================================================================
FAILED tests/test_recipe_bot.py::test_fits_a_tweet[entry8-groq/llama3-70b-8192] - assert 151 <= 140
FAILED tests/test_recipe_bot.py::test_fits_a_tweet[entry10-gpt-4o] - AssertionError: assert 148 <= 140
FAILED tests/test_recipe_bot.py::test_fits_a_tweet[entry11-groq/llama3-70b-8192] - assert 320 <= 140
============================================================= 3 failed, 9 passed, 1 warning in 45.21s ============================================================

Видно, что GPT-4 и Llama3 не справились с этим тестом, причем оба не справились с последним примером пользователя. Но прежде чем мы пойдем дальше, вы заметили, сколько времени ушло на выполнение наших тестов? Мы потратили 45 секунд, и это только первый тест, а сколько времени потребуется этому набору, когда проект вырастет.

Проблема № 4: LLM медленные

Даже если groq безумно быстр, он все равно на порядки медленнее, чем обычный код, который мы тестируем, но на самом деле в нашем случае эти LLM выполняются не на нашей машине, а через API, так что проблема в том, что они вызываются последовательно, а не параллельно.

К счастью, мы можем использовать pytest‑asyncio‑cooperative (вы можете использовать ветку https://github.com/willemt/pytest‑asyncio‑cooperative/pull/66, если возникнут проблемы с повторными попытками). Эта библиотека позволяет тестам pytest запускаться одновременно:

- @pytest.mark.asyncio
+ @pytest.mark.asyncio_cooperative
  @pytest.mark.parametrize("entry, model", product(entries.itertuples(), models))

  async def test_fits_a_tweet(entry, model):
      recipe = await recipe_bot.generate_tweet(
          input=entry.input,
          model=model,
      )

      assert len(recipe) <= 140

Теперь давайте пробежимся еще раз:

> pytest

============================================================================ FAILURES =============================================================================
FAILED tests/test_recipe_bot.py::test_fits_a_tweet[entry9-gpt-3.5-turbo] - AssertionError: assert 141 <= 140
FAILED tests/test_recipe_bot.py::test_fits_a_tweet[entry11-groq/llama3-70b-8192] - assert 308 <= 140
FAILED tests/test_recipe_bot.py::test_fits_a_tweet[entry10-gpt-4o] - AssertionError: assert 148 <= 140
============================================================= 4 failed, 8 passed, 1 warning in 27.88s ==============================================================

Тесты, по‑прежнему не выполняются, но теперь это занимает 27 с, что конечно лучше, но все равно занимает много времени из‑за повторных попыток, которые происходят последовательно. Если мы уберем повторные попытки, все будет выполняться за 2 с.

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

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

Проблема №5: Порог прохождения

Так же, как мы делали ранее повторные попытки для каждого теста и устанавливали минимальный процент успешных попыток, есть примеры, которые ваш LLM не сможет решить правильно, сколько бы раз вы ни пытались, однако это не значит, что он бесполезен: правильно решить примеры из вашего набора данных в 80% случаев все равно лучше, чем в 50% случаев, и опять же, вы четко осознаете ограничения своих тестов, а не находитесь в неведении.

В нашем случае, например, может быть нормально, что GPT-4 или Llama3 не справляются с одной из задач после трех попыток, мы все равно считаем это достаточно хорошим результатом. Значит, нам нужен общий процент успешного прохождения. Для этого мы воспользуемся библиотекой langevals, которая занимается оценками для LLM, но также предоставляет аннотацию pass_rate для pytest:

 entries = pd.DataFrame(
      {
          "input": [
              "Generate me a recipe for a quick breakfast with bacon",
              "Generate me a recipe for a lunch using lentils",
              "Generate me a recipe for a vegetarian dessert",
-             "Generate me a super complicated recipe for a dinner with a lot of ingredients",
          ],
      }
  )

 

  @pytest.mark.asyncio_cooperative
  @pytest.mark.flaky(max_runs=3, min_passes=2)
+ @pytest.mark.pass_rate(0.8)
  @pytest.mark.parametrize("entry, model", product(entries.itertuples(), models))
  async def test_fits_a_tweet(entry, model):
      recipe = await recipe_bot.generate_tweet(
          input=entry.input,
          model=model,
      )


      assert len(recipe) <= 140

С помощью этого параметра мы говорим, что минимальное количество успешных попыток, которое мы ожидаем, составляет 80%, мы не против 20% неудачных, если большинство из них (80%) действительно меньше 140 символов.

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

> pytest

======================================================================= test session starts =======================================================================

x.xx......

============================================================ 8 passed, 3 xfailed, 1 warning in 20.42s ============================================================

Отлично! Теперь наш набор тестов проходит, хотя не все тесты прошли полностью. Обратите внимание на 3 xfailed. Тесты не прошли, но были «прощены», либо потому что они были повторно опробованы и сработали, либо потому что они находятся в пределах нормы прохождения.

Заключение

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


Если вам интересно углубиться в темы, которые затрагивают не только теорию, но и реальные подходы к работе с LLM, вот несколько уроков, которые могут быть полезны:

Теги:
Хабы:
+1
Комментарии0

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS