Главная / Статьи

Как ИИ-агент для исправления документов обманул сам себя: ложные положительные результаты в среде Docker Sandbox

Агент для исправления документации LangGraph выпустил бесполезное решение, после чего нарушил работу пакетов src-layout. Проблема, связанная с средой Docker Sandbox, и настоящее решение были найдены в компоненте click.

Издатель: REACTAPP.TOP7 мин чтения1666 слов

Источники: **I built an AI Doc-Fixing Agent. Here’s every way it fooled itself first.**

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

- print(tqdm.__version__, sys.version, sys.platform)
+ print(tqdm.__version__, sys.version, sys.platform)

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

Что делает DocuPatch

DocuPatch — это агент, управляемый LangGraph. Он просматривает файлы формата Markdown в репозитории GitHub, извлекает блоки кода на Python и запускает их в изолированном контейнере Docker. Образцы, действительно не работающие, отправляются в Gemini для ремонта; исправленный код снова проверяется в том же изолированном окружении, и только подтвержденный результат превращается в ветку и запрос к слиянию.

Структура репозитория:

  • agent/ingest.py клонирует репозиторий и находит файлы Markdown.
  • agent/graph.py запускает процесс LangGraph для каждого файла.
  • agent/parser.py извлекает блоки кода на Python.
  • agent/tester.py запускает каждый фрагмент кода внутри контейнера Docker.
  • agent/context_collector.py собирает соответствующие Python-файлы и код для фрагмента.
  • agent/fixer.py генерирует исправленный фрагмент.
  • agent/github_client.py создаёт форк, сохраняет исправление и открывает PR-запросы.
  • tests/ содержит тесты для каждой части.
  • Идея проста: код документации портится со временем. API меняются, функции переименовываются, примеры перестают работать, и никто этого не замечает, пока кто-то не вставит неисправный фрагмент из README. Агент автоматизирует выявление таких проблем. Он находит подозрительный фрагмент, проверяет его на работоспособность путём выполнения, запрашивает у большой языковой модели исправление, снова проверяет, работает ли исправление, и только после этого открывает pull-реквест.

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

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

    Первый тест: tqdm

    Первый реальный тест был направлен на tqdm/tqdm:

    Found 9 Markdown file(s). Starting audit...
    
    Auditing: .github\ISSUE_TEMPLATE\bug.md
      Generating repair for snippet #1
      Verifying generated repair for snippet #1
      Publishing verified repair(s)...
      VERIFIED REPAIR: Branch published
    

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

    - print(tqdm.__version__, sys.version, sys.platform)
    + print(tqdm.__version__, sys.version, sys.platform)
    

    Опять эти две строки идентичны. Gemini ничего не изменил и воссоздал код байт за байтом. Система протестировала фрагмент кода, объявила его неисправным, «исправила» его, вернув тот же код, снова протестировала, увидела успешный результат и сделала коммит без изменений в форке.

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

    Второй тест: pallets/click

    В репозитории pallets/click этот паттерн был совершенно очевиден:

    Auditing: docs\advanced.md
      Generating repair for snippet #2
      Verifying generated repair for snippet #2
      [Reflection Loop 2/3] Revising repair for snippet #2
      ...
      [Reflection Loop 3/3] Revising repair for snippet #3
      NOT PUBLISHED: Generated repair failed verification:
        ModuleNotFoundError: No module named 'click'
    

    Ошибка каждый раз. Шесть файлов проходили по трем полным циклам отражения, и каждая неудача сводилась к одной и той же ошибке ModuleNotFoundError: No module named 'click'.

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

    Открытие файла tester.py позволило разобраться в механизме:

    command.extend(["-v", f"{project_dir}:/workspace", "-w", "/workspace", "-e", "PYTHONPATH=/workspace"])
    

    Внутри контейнера клонированный репозиторий монтируется, и переменная PYTHONPATH устанавливается на уровень корня репозитория. Это работает, когда каталог пакета находится прямо там, но приводит к ошибкам, когда пакет расположен на уровень ниже. У проекта Click структура такова: его файл pyproject.toml хранит реальный код внутри каталога src/click/, а не в каталоге click/ в корне проекта, как это принято во многих современных проектах. При настройке PYTHONPATH=/workspace интерпретатор искал код в каталоге, в котором вообще не было каталога click.

    Ручная проверка вне пайплайна подтвердила это:

    docker run --rm -v "C:\temp\click_test:/workspace" -w /workspace -e PYTHONPATH=/workspace python:3.10-slim python -c "import click"
    # ModuleNotFoundError: No module named 'click'
    
    docker run --rm -v "C:\temp\click_test:/workspace" -w /workspace -e PYTHONPATH=/workspace/src python:3.10-slim python -c "import click; print(click.__version__)"
    # import works... but:
    # importlib.metadata.PackageNotFoundError: No package metadata was found for click
    

    Второй результат принес наибольшую пользу. Указание PYTHONPATH на правильную папку решило проблему с импортом, однако click.__version__ по-прежнему вызывал ошибку, потому что текущие версии click формируют значение __version__ с помощью importlib.metadata, который считывает метаданные установленных пакетов, а не исходные файлы. Такие метаданные появляются только после реальной установки с помощью pip, никогда после простого изменения пути.

    Таким образом, возникли две проблемы одновременно. PYTHONPATH указывал на неправильную папку для репозиториев с структурой src-layout. Даже при правильном указании пути простой трюк с PYTHONPATH не гарантирует корректной установки пакета, тогда как всё больше пакетов требуют полноценной установки, а не просто возможности импорта.

    Стоит упомянуть один огибающий маршрут, поскольку именно он оказался наиболее поучительным. Много времени было потрачено на размышления о особенности монтирования Docker, доступного только в Windows, с экспериментами с кавычками, форматами путей и списками аргументов по сравнению с строками оболочки. Настоящее объяснение оказалось простым: тестовая папка была пуста, потому что предыдущая команда git clone незаметно не смогла её заполнить, и никто этого не проверил. Пустой список, полученный при выполнении команды dir C:\temp\click_test, не был «отсутствием ответа», а самим ответом, но его трижды проигнорировали, прежде чем кто-то решился его прочитать.

    Решение: устанавливайте, а не догадывайтесь

    Настоящее решение — не более умные предположения относительно пути, а полное отказ от предположений. Команда pip install -e . читает собственный файл pyproject.toml или setup.cfg проекта, определяет правильное местоположение пакета независимо от его структуры и записывает достоверные метаданные установки, благодаря чему вызовы importlib.metadata ведут себя так же, как у любого реального пользователя библиотеки.

    Логика нового файла tester.py выполняется в следующем порядке:

    • Если существуют файлы pyproject.toml, setup.py или setup.cfg, среда сандбокса создает образ Docker и запускает внутри него команду pip install -e .. Это «режим установки», при котором сбои при импорте считаются достоверными.
    • Если метаданных упаковки нет, но имеется файл requirements.txt, применяется более ранний подход с использованием PYTHONPATH, то есть «режим pythonpath».
  • В режиме pythonpath, если фрагмент кода терпит неудачу из-за невозможности импортировать собственный пакет проекта, ставьте метку ENV_ERROR — честное указание на «невозможность определения», а не FAIL.
  • После изменений существующий набор тестов показал, что 26 из 27 тестов прошли без изменений. Один неудачный тест действительно должен был провалиться, поскольку он проверял старую сигнатуру функции, которая была изменена намеренно. Ожидания были обновлены, и три новых теста покрыли крайний случай, в результате чего общее количество пройденных тестов составило 74.

    Настоящая ошибка в документации click

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

    docs/commands.md описывает цепочку операций в CLI, и в одной из примерных функций указано:

    @cli.result_callback()
    def process_pipeline(processors, fin):
    - iterator = (x.rstrip("\r\n") for x in input)
    + iterator = (x.rstrip("\r\n") for x in fin)
    

    Функция на самом деле принимает fin — объект управления файлом, предоставляемый функцией click. Однако в документации используется цикл над input, который на самом деле относится к встроенной функции Python input(), а не к файлу. Использование такого цикла недопустимо и приводит к сбою сразу при запуске кода, но пользователь не заметит этого, поскольку название кажется разумным.

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

    Уроки, извлечённые в процессе отладки

    • Проверьте вердикт, прежде чем доверять результату. Такие метки, как «Пройдено» или «Опубликовано», надежны лишь настолько, насколько надежен тест, стоящий под ними. Проблема с tqdm проявилась только потому, что была открыта разница между версиями, и зеленая галочка не была воспринята без проверки.
    • Идентичные сбои во многих случаях указывают на общую причину. Шесть файлов, шесть независимых попыток использования LLM и одно сообщение об ошибке свидетельствуют о проблеме на уровне всей системы.
    • Сначала наблюдайте, а уже потом формулируйте теории. Прошли часы на размышления о путях в Docker и Windows, прежде чем кто-то проверил, действительно ли в папке с тестами есть файлы.
    • Неудачный тест может лишь подтвердить проблему, но не усугубить её. Когда изменение сигнатуры функции run_snippet привело к сбою одного теста, правильным вопросом было то, что именно утверждал этот тест, а не что именно сломалось.

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