Распаковка словарей (**) в smolagents вызывает ложную ошибку NoneType. Узнайте, как фаззинг помог найти и исправить баг, и что делать, чтобы избежать циклов.
Вы используете Python-агентов, которые генерируют код? Тогда вы могли столкнуться с ситуацией, когда агент зацикливается, а сообщение об ошибке указывает на несуществующий None. В этой статье я расскажу, как фаззинг помог найти скрытый баг в песочнице smolagents, связанный с распаковкой словарей, и как его исправить.
Одна из самых частых операций, которую генерирует LLM — слияние словарей через **. Например:
config = {
**{"temperature": 0.7, "max_tokens": 512},
"top_p": 0.9
}В smolagents этот код вызывает ошибку:
InterpreterError: NoneType is not supportedПроблема в том, что None в коде нет. Сообщение об ошибке вводит в заблуждение, заставляя искать несуществующее значение.
В абстрактном синтаксическом дереве Python (ast) литерал словаря хранит ключи и значения в двух параллельных списках. Для обычной записи ключ — это узел AST. Для распаковки (**mapping) ключ равен None — это маркер, сигнализирующий, что это не пара ключ-значение, а распаковка.
Песочница LocalPythonExecutor проходила по expression.keys и вызывала evaluate_ast(key, ...) для каждого ключа. Когда ключ оказывался None, вызов не попадал ни в одну ветку isinstance и проваливался в общий обработчик, который выбрасывал InterpreterError. Таким образом, маркер распаковки интерпретировался как выражение, и модель получала ошибку о несуществующем None.
Агент перехватывает ошибку и отправляет модели сообщение «вот что пошло не так, попробуй снова». Но поскольку сообщение указывает на NoneType, а в коде его нет, модель не может исправить ошибку. Она повторяет тот же самый синтаксис снова и снова. Каждый шаг тратит реальный вызов LLM и слот в бюджете шагов, пока выполнение не завершится неудачей.
Один баг, одно вводящее в заблуждение сообщение, три одинаковых сбоя. Без инструмента вроде Sentry, который считает события, вы бы никогда не увидели цикл — только тихо деградирующий запуск.
Решение заключается в том, чтобы обрабатывать словарь парами, а не слепо вычислять ключи. None в ключе означает «слить это отображение»:
result = {}
for key_node, value_node in zip(expression.keys, expression.values):
if key_node is None:
value = evaluate_ast(value_node, *common_params)
if not hasattr(value, "keys"):
raise InterpreterError(f"'{type(value).__name__}' object is not a mapping")
result.update(value)
else:
key = evaluate_ast(key_node, *common_params)
result[key] = evaluate_ast(value_node, *common_params)
return resultЭто полностью соответствует поведению CPython: распаковки сливаются по порядку, более поздние ключи перезаписывают ранние, а попытка распаковать не-отображение вызывает ошибку 'list' object is not a mapping.
Изначально я проверял распаковку через isinstance(value, Mapping). Но ревьюер от OpenAI Codex указал: CPython не требует наличия ABC Mapping, достаточно объекта с методом keys(). Поскольку песочница позволяет пользователям определять свои классы, утиная типизация с keys() и __getitem__() была бы несправедливо отвергнута. Я заменил проверку на hasattr(value, "keys") и добавил тест для этого случая. ИИ написал код, ИИ проверил код, я просто вёл счёт.
На обновлённой версии тот же код просто работает:
app: step 1 ok, config = {'temperature': 0.7, 'max_tokens': 512, 'top_p': 0.9}Один шаг, никакого цикла, никакого призрачного None.
NoneType воспроизводится на текущем main и версии 1.26.0Худшие баги агентов не бросают в вас красный стектрейс. Они передают модели правдоподобное, но неверное сообщение и позволяют ей вежливо ошибаться снова и снова. Считайте свои события. Используйте инструменты мониторинга, чтобы видеть циклы, которые иначе остались бы незамеченными. А если вы пишете песочницу для выполнения кода — не забывайте про распаковку словарей.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →