
수학식 한 줄이 내 AI 에이전트를 영원히 멈추게 했다: 타임아웃이 작동하지 않고 아무것도 하지 않았다
요약
smolagents 라이브러리에서 거듭제곱 연산과 같은 대규모 수치 계산 시 GIL 점유로 인해 타임아웃 설정이 무력화되는 버그를 분석합니다. CPython의 임의 정밀도 연산이 스레드 전환을 막아 프로세스가 영원히 멈추는 현상을 다룹니다.
핵심 포인트
- smolagents의 타임아웃은 스레드 기반으로 설계되어 GIL 점유 시 작동하지 않음
- 대규모 수치 연산은 단일 C 호출 내에서 실행되어 바이트코드 경계가 없음
- GIL을 얻지 못하면 메인 스레드의 타임아웃 감지 로직이 실행될 수 없음
- AST 연산 횟수 제한(MAX_OPERATIONS)만으로는 이러한 연산 병목을 막을 수 없음
이 글은 DEV x Sentry Bug Smash 챌린지를 위한 저의 두 번째 게시물입니다. 첫 번째 게시물은 혼란스러운 에러 메시지를 동반한 크래시(crash)에 관한 것이었습니다. 이번 사례는 그 반대이며 훨씬 더 무섭습니다. 크래시도 없고, 메시지도 없고, 이벤트도 없습니다. 그저 침묵뿐입니다.
아무것도 보내주지 않는 버그
smolagents는 LLM(대규모 언어 모델)이 생성한 Python 코드를 타임아웃(timeout) 설정이 된 샌드박스 실행기(sandboxed executor)에서 실행합니다. 이슈 #2473에 따르면, 모델이 생성한 수학식 한 줄이 이를 완전히 무력화한다고 주장합니다:
from smolagents.local_python_executor import LocalPythonExecutor
executor = LocalPythonExecutor(additional_authorized_imports=[], timeout_seconds=2)
...
저는 2초의 타임아웃을 설정하고, 20초 후에 작동하도록 faulthandler 덤프를 설정하여 이를 실행했습니다. 타임아웃은 전혀 발생하지 않았습니다. 프로세스는 외부에서 강제 종료(kill)될 때까지 얼어붙은 상태로 머물러 있었습니다. 이러한 표현식을 생성하는 에이전트(그리고 "이 거대한 숫자를 계산해줘"는 에이전트들이 시도하는 전형적인 작업입니다)는 호스트 프로세스를 영원히 멈추게 만듭니다.
해당 이슈에는 댓글도, PR(Pull Request)도 없었습니다. 이제 제가 작성합니다.
타임아웃이 당신을 속이는 이유
smolagents의 타임아웃은 스레드(thread) 기반입니다. 워커 스레드(worker thread)가 코드를 실행하면, 메인 스레드(main thread)는 future.result(timeout=2)에서 대기합니다. CPython은 바이트코드(bytecode) 명령어 사이에서 스레드를 전환하기 때문에, 이러한 설계는 거의 모든 상황에서 괜찮습니다.
하지만 10 ** 10 ** 8은 "거의 모든 상황"에 해당하지 않습니다. CPython은 임의 정밀도(arbitrary precision)의 **, <<, * 연산을 시작부터 끝까지 GIL(Global Interpreter Lock)을 점유하는 단일 C 호출 내부에서 계산합니다. 바이트코드 경계도 없고, 스레드 전환도 없으며, 타임아웃도 없습니다. 결과값은 약 4억 비트(bits)에 달할 것입니다. 계산에는 몇 분에서 몇 시간 사이의 시간이 소요됩니다. 당신의 와치독(watchdog)이 깨어나려면 GIL이 필요한데, 이를 절대 얻을 수 없습니다.
faulthandler 덤프를 통해 상황을 구체화해 보니, 이슈에 설명된 것보다 상황이 더 심각했습니다:
Thread 0x16d1f3000 (worker):
File "local_python_executor.py", line 753 in evaluate_binop <- 거듭제곱(pow) 계산 중
...
메인 스레드는 여전히 ThreadPoolExecutor.submit 내부에서 멈춰 있었습니다. future.result에 도달조차 하지 못했습니다. 2초 타이머는 아예 작동조차 하지 않았습니다.
기존의 MAX_OPERATIONS 가드(1,000만 개의 AST 연산)도 도움이 되지 않았습니다. 이는 불과 몇 개의 AST 노드에 불과했습니다. 모든 비용은 그중 하나의 노드 내부에서 발생하고 있었습니다.
Sentry의 관점: 부재(absence)를 모니터링하기
첫 번째 사례는 소음이 심한 실패(noisy failure)에 관한 것이었습니다. 이 버그는 그 반대입니다. 패치되지 않은 PyPI 릴리스(1.26.0)에서 시뮬레이션된 에이전트 실행에 새로운 Sentry 프로젝트를 연결했더니, 가능한 가장 불안한 결과가 나왔습니다. 바로 '아무것도 없음'이었습니다. 이벤트도, 트랜잭션도 없는 빈 프로젝트였습니다. 프로세스가 트랜잭션 도중에 얼어붙었고, SDK는 데이터를 플러시(flush)할 기회를 전혀 얻지 못했습니다.
교훈: 프리즈(freeze) 유형의 버그를 위해서는 감독자(supervisor)가 필요합니다. 저는 워커(worker)에게 25초의 시간을 준 뒤, 이를 종료하고 목격한 내용을 보고하는 작은 와치독(watchdog) 프로세스를 추가했습니다. 이때 증거로서 워커의 faulthandler 스택을 첨부했습니다:
watchdog: starting worker (before) with 25s budget
worker: smolagents 1.26.0
worker: step 1 executing 'result = 10 ** 10 ** 8'...
...
결과적으로 생성된 Sentry 이슈는 한 곳에 모든 이야기를 담고 있습니다: 얼어붙은 프레임(local_python_executor.py의 evaluate_binop, 753행)이 첨부된 worker_faulthandler_stack extra에 바로 위치하며, 환경 태그는 before로 지정되어 감독자 외에는 아무것도 처리하지 못한 상태로 남아 있습니다.
Sentry의 Seer는 해당 이슈에 대해 근본 원인 분석(root cause analysis)을 수행했고, 독립적으로 동일한 결론에 도달했습니다: 시그널(signal) 및 스레드 중단(thread interruption)에는 GIL이 필요하며, 단일 C 레벨의 큰 정수(big-int) 연산은 이를 절대 해제하지 않는다는 것입니다.
Seer의 판결(원문 그대로):
CodeAgent의 2초 타임아웃은 Python의 시그널 기반 중단 (signal-based interruption) 방식을 사용하는데, 이는 GIL을 점유하고 있는 중단 불가능한 C 레벨의 큰 정수 (big-int) 연산 중에는 발생할 수 없습니다. [...] 단일 대형 정수 산술 연산은 제어권을 양보하지 않고 GIL을 지속적으로 보유하는 하나의 C 레벨 호출로서 완전히 실행됩니다. CPython은 중단 불가능한 C 확장 (C extension) 호출 중에 시그널을 전달하거나 스레드를 전환할 수 없으므로, 해당 연산이 지속되는 동안 타임아웃 콜백 (timeout callback)이 실행되지 않습니다.
해결책: 중단할 수 없다면, 시작 자체를 거부하라
연산이 진행 중인 도중에 이를 강제로 종료하는 것은 Python에서 불가능합니다. 하지만 피해를 예측하는 것은 $O(1)$입니다. 이제 실행기 (executor)는 정수에 대해 **, << 또는 * 연산을 실행하기 전에, 피연산자 (operands)의 비트 길이 (bit length)를 바탕으로 결과의 비트 길이를 추정합니다:
if op == "**":
estimated_bits = left.bit_length() * right # 상한선 (upper bound)
elif op == "<<":
...
100만 비트(약 30만 자리, 여전히 넉넉한 수치)를 초과할 경우, 정보가 담긴 InterpreterError를 발생시킵니다:
Operation '**' would produce an integer of around 400000000 bits, exceeding
the maximum of 1000000 bits allowed. Use smaller operands, or
pow(base, exp, mod) for modular exponentiation.
이 메시지는 매우 중요합니다. 에이전트 (agent)는 이 메시지를 모델 (model)에게 전달하며, 모델은 실제로 이에 따라 행동할 수 있습니다. 즉, 모듈러 거듭제곱 (modular exponentiation)은 빠르고 정당한 연산이기에 제한을 받지 않는 pow(base, exp, mod)를 사용하는 것입니다. 에이전트는 호스트 (host)를 멈추게 하는 대신 다음 단계에서 복구됩니다.
패치된 빌드에서 실행한 결과: 가드 (guard)가 0.0초 만에 해당 표현식을 거부하며, 에러는 Sentry에 일반적인 조치 가능한 이슈 (actionable issue)로 기록되고 2단계가 정상적으로 실행됩니다.
watchdog: starting worker (after) with 25s budget
worker: smolagents 1.27.0.dev0
worker: step 1 error surfaced to the model: InterpreterError: ...
...
로봇이 내 로봇 수정을 검토했다
내가 PR (Pull Request)을 올린 지 몇 분 만에, OpenAI의 Codex 리뷰어가 실제 허점을 찾아냈다. 나의 가드 (guard) 코드가 type(x) is int를 체크하고 있었는데, 이는 bool과 int의 서브클래스 (subclass)가 통과되도록 허용하고 있었다. True << 10**9와 class BigInt(int)는 여전히 중단 불가능한 C 호출 (C calls)에 도달했다. isinstance를 사용하여 수정하였고, 두 사례 모두 회귀 테스트 (regression tests)로 추가했다. AI가 버그 유형을 찾아냈고, AI가 이를 수정했으며, AI가 수정을 검토했다. 나는 그저 방향을 잡았을 뿐이다.
수치
- 프리즈 (Freeze) 현상은 20초 이상에서 재현됨 (그대로 두었다면 몇 시간 동안 실행되었을 것), 외부 킬 (external kill) 명령이 필요함
- 수정 후에는 동일한 표현식이 0.0초 만에 거부됨
- 9가지 폭발적 패턴 차단:
**,<<, 연결된*, 모든 복합 할당 형태 (augmented forms),pow(a, b),bool및int서브클래스 변형 - 10가지 정상적인 연산이 영향을 받지 않음을 확인: 반복적인
*=를 통한 100!,pow(7, 2**64, 97), float pow,1 ** 10**9 - 19개의 새로운 테스트 추가, 전체 테스트 파일 416개 통과, ruff 클린 (clean)
- 차단된 패턴 테스트들은 패치되지 않은 메인 (main) 브랜치에서 영원히 멈춤. 나는 stash와 킬 스위치 (kill switch)를 사용하여 정직한 방식으로 이를 확인했다.
링크
- Issue: https://github.com/huggingface/smolagents/issues/2473
- PR: https://github.com/huggingface/smolagents/pull/2551
- Entry #1: https://dev.to/himanshu_748/i-fixed-a-smolagents-bug-that-confused-everyone-who-hit-it-with-sentry-watching-the-whole-time-1im
가장 무서운 버그는 새벽 3시에 당신을 호출하는 버그가 아니다. 그 어떤 것도 당신을 호출하지 못하도록 확실히 보장해 버리는 버그다. 침묵에 대비해 계측(instrument)하라.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기