
내 에이전트가 동일한 유효 코드를 세 번이나 재시도하게 만든 smolagents 버그
요약
smolagents 라이브러리의 LocalPythonExecutor에서 발생하는 딕셔너리 언패킹 버그를 분석하고 해결 과정을 다룹니다. 유효한 Python 코드가 AST(추상 구문 트리) 처리 과정에서 NoneType 에러를 유발하여 에이전트가 불필요한 재시도를 반복하게 만드는 문제를 설명합니다.
핵심 포인트
- smolagents의 샌드박스 내 딕셔너리 언패킹 시 발생하는 InterpreterError 버그 발견
- AST 처리 과정에서 스프레드 마커(None)를 잘못된 키로 인식하여 발생하는 문제
- 퍼징(Fuzzing) 기법을 활용하여 보고되지 않은 오픈 이슈를 찾아내는 방법 제시
- 잘못된 에러 메시지가 에이전트의 디버깅 및 실행 효율을 저해함
DEV x Sentry Bug Smash의 세 번째 게시물입니다. Entry 1은 혼란스러운 메시지를 동반한 크래시(crash)였고, Entry 2는 타임아웃(timeout)이 잡아낼 수 없었던 프리즈(freeze) 현상이었습니다. 이번 사례는 더 조용하고 교활합니다. 즉, 유효한 Python 코드임에도 불구하고 샌드박스(sandbox)가 완전히 잘못된 부분을 가리키며 에러를 발생시키며 거부하는 경우입니다.
오픈 이슈가 고갈되면, 퍼징(fuzz)하라
세 번째 게시물에 도달했을 때, 명백한 smolagents의 오픈 이슈들은 이미 누군가 점유했거나 경쟁 중인 PR(Pull Request)이 있었습니다. 그래서 이슈 트래커(issue tracker)를 읽는 대신, 저는 가장 신뢰할 수 없는 코드를 실행하는 smolagents의 부분인 LocalPythonExecutor에 작은 퍼저(fuzzer)를 겨냥했습니다. 이는 모델이 생성한 Python을 실행하는 샌드박스(sandbox)입니다.
이 방법은 지루하지만 효과적입니다. 일반적이고 유효한 Python 코드를 한 번에 하나씩 입력하고, InterpreterError를 발생시키는 모든 것을 표시하는 것입니다. 샌드박스가 실행을 거부하는 유효한 Python은 정의상 버그입니다. 왜냐하면 모델은 유효한 Python을 작성하고 그것이 작동하기를 기대하기 때문입니다.
이 과정을 통해 단 한 번의 오후 만에 보고되지 않은 네 가지 버그를 찾아냈습니다. 이 포스트는 제가 해결하여 배포한 버그, 즉 **dict unpacking (딕셔너리 언패킹)**에 관한 것입니다.
버그
config = {**{"temperature": 0.7, "max_tokens": 512}, "top_p": 0.9}
**를 사용하여 딕셔너리(dict)를 병합하는 것은 LLM이 작성하는 가장 흔한 작업 중 하나입니다. smolagents 환경에서는 다음과 같은 에러와 함께 실패합니다:
InterpreterError: NoneType is not supported.
해당 라인 어디에도 None은 존재하지 않습니다. 이 메시지는 존재하지도 않는 null 값을 찾게 만듭니다.
발생하는 이유
Python의 AST(Abstract Syntax Tree, 추상 구문 트리)에서 딕셔너리 리터럴(dict literal)은 키(key)와 값(value)을 두 개의 병렬 리스트에 유지합니다. 일반적인 항목의 경우 키는 AST 노드입니다. 하지만 **mapping 스프레드(spread) 항목의 경우, 키는 문자 그대로 None이며, 이는 "이것은 키/값 쌍이 아니라 스프레드이다"라고 말하는 신호입니다.
smolagents는 expression.keys를 순회하며 모든 키를 검사하고, 각 키에 대해 evaluate_ast(key, ...)를 호출했습니다. 키가 None인 경우, 해당 호출은 모든 isinstance 분기를 통과하여 모든 것을 잡아내는 raise InterpreterError(f"{type} is not supported")로 떨어집니다. 결과적으로 스프레드(spread) 마커가 마치 하나의 표현식(expression)인 것처럼 평가되었고, 모델은 자신이 작성하지도 않은 None에 대해 책임을 떠안게 되었습니다.
침묵이 비용이 많이 드는 이유
Sentry 뷰를 통해 명확해진 부분은 다음과 같습니다. 이 에러는 *처리(handled)*됩니다. 즉, 에이전트가 에러를 포착하여 모델에게 "무엇이 잘못되었으니 다시 시도하라"는 식으로 다시 전달합니다. 하지만 메시지에는 NoneType이라는 이름이 명시되어 있고, 모델의 코드에는 None이 없기 때문에 모델은 이에 대응할 수 없습니다. 모델은 정확히 동일한 유효한 구문을 재시도합니다. 그리고 또다시 반복합니다. 매 단계마다 실제 LLM 호출이 발생하고 스텝 예산(step budget)이 소모되며, 결국 실행이 포기될 때까지 계속됩니다.
하나의 버그, 하나의 오해를 불러일으키는 메시지, 세 번의 동일한 실패:
단일 이슈에서 세 번의 이벤트가 발생하는 것은 노이즈가 아닙니다. 이는 에이전트가 루프(loop)에 빠진 것이며, Sentry가 이 이벤트들을 집계하지 않았다면 여러분은 루프를 전혀 인지하지 못한 채 그저 성능이 조용히 저하된 실행 결과만을 보게 되었을 것입니다. Sentry의 Seer는 동일한 이벤트를 읽고 정확한 근본 원인에 도달했습니다:
smolagents의
LocalPythonExecutor는 딕셔너리 언패킹(**) 구문을 처리하지 못합니다: ast.Dict 내의None키가 지원되지 않는 타입 에러를 유발합니다. [...]evaluate_ast(None, ...)는 어떤isinstance분기에도 일치하지 않고else절로 떨어집니다. 인터프리터는InterpreterError: NoneType is not supported를 발생시키며, 에이전트는 동일한 코드로 재시도하며 루프 속에서 스텝을 낭비합니다.
해결책
키를 맹목적으로 평가하는 대신, 딕셔너리를 쌍(pairwise)으로 평가하십시오. None 키는 "이 매핑을 병합하라"는 의미입니다:
result = {}
for key_node, value_node in zip(expression.keys, expression.values):
if key_node is None:
...
이는 CPython과 정확히 일치합니다: 스프레드 (spread)는 순서대로 병합되며, 나중에 나온 키가 우선권을 갖고, 매핑(mapping)이 아닌 것을 언패킹(unpacking)하면 'list' object is not a mapping 오류가 발생합니다.
리뷰어가 내 수정 사항이 너무 엄격하다고 지적했습니다
처음에는 스프레드(spread)를 isinstance(value, Mapping)로 제한했습니다. PR(Pull Request)을 올린 지 몇 분 만에 OpenAI의 Codex 리뷰어가 이를 지적했습니다 (P2): CPython은 Mapping ABC (Abstract Base Class)를 요구하지 않으며, 단지 keys() 메서드를 가진 객체만을 요구합니다. 샌드박스(sandbox)는 사용자가 직접 클래스를 정의할 수 있게 하므로, keys()와 __getitem__()을 가진 덕 타이핑 (duck-typed) 매핑이 잘못 거부될 수 있었습니다. 저는 체크 방식을 hasattr(value, "keys")로 변경하고, 정확히 그 케이스에 대한 테스트를 추가했습니다. AI가 코드를 작성했고, AI가 코드를 리뷰했으며, 저는 점수를 기록했습니다.
수정 후
패치된 빌드에서는 동일한 라인이 다음과 같이 실행됩니다:
app: step 1 ok, config = {'temperature': 0.7, 'max_tokens': 512, 'top_p': 0.9}
한 번의 단계로 끝났으며, 루프(loop)나 유령 None은 발생하지 않았습니다.
수치
- 샌드박스를 통해 유효한 Python을 퍼징 (fuzzing)하여 발견된 보고되지 않은 버그 4개; 이것이 첫 번째 수정 사항입니다.
- 현재
main브랜치 및 1.26.0 버전에서 재현되는 오해의 소지가 있는NoneType오류 - 발생 시마다 에이전트 단계 3개 낭비, Sentry가 이벤트를 집계하기 때문에 확인 가능함
- 9개의 새로운 테스트: 스프레드 (spreads), 이중 스프레드 (double spreads), 양방향 오버라이드 순서 (override order), 덕 타이핑 (duck-typed) 매핑 클래스, 빈 스프레드 (empty spread), 비매핑 (non-mapping) 거부
- 406개 통과, ruff clean
링크
- Issue: https://github.com/huggingface/smolagents/issues/2552
- PR: https://github.com/huggingface/smolagents/pull/2553
세 가지 항목 모두에 나타나는 패턴은 다음과 같습니다: 최악의 에이전트 버그는 당신에게 빨간색 스택 트레이스 (stack trace)를 던져주지 않습니다. 대신 모델에게 그럴듯하지만 틀린 메시지를 전달하여, 모델이 정중하게, 반복적으로 실패하도록 만듭니다. 당신의 이벤트 수를 세어보세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기