My benchmark's Python column was N/A for a year — CPython's 4300-digit limit, and eight other bugs
Submission for DEV's Summer Bug Smash — Clear the Lineup track. The...
개요 #
한 개발자가 직접 만든 멀티 언어 성능 벤치마크에서, Python 결과 칸이 1년 내내 N/A로 남아 있었다. 그는 그 빈칸을 한 번도 의심하지 않았다고 고백한다. 원인을 파고들자 단순한 버그 하나가 아니라 서로 얽힌 아홉 개의 문제가 튀어나왔다. 그중 핵심은 CPython이 기본값으로 걸어둔 "정수를 문자열로 바꿀 때 4,300자리 제한"이었다.
이 글은 a2a-benchmark라는 오픈소스 프로젝트에서 발견된 아홉 개 버그와 그 수정 과정을 정리한 것이다. 측정 도구를 만들 때 흔히 저지르는 실수들이 촘촘하게 담겨 있다.
어떤 벤치마크인가 #
a2a-benchmark는 네 개 언어로 구현한 A2A(Agent-to-Agent) 성능 측정 도구다. Python과 Go는 ADK를 거쳐 Gemini 툴 콜링 방식으로, Node.js와 Rust는 직접 핸들러로 동작한다. 각 에이전트는 Lucas–Lehmer 판정법으로 메르센 소수를 계산하고, 하네스가 N=1부터 24까지 훑으며 계산 시간과 왕복 시간(RTT)을 차트로 그린다.
문제는 커밋된 결과가 N=22에서 멈춰 있었다는 점이다. 아무도 그 지점에서 왜 데이터가 끊겼는지 캐묻지 않았다.
가장 큰 버그: 데이터 한 칸이 아예 존재하지 않았다 #
CPython 3.11부터는 기본 설정으로 정수를 문자열로 변환할 때 4,300자리까지만 허용한다. DoS 공격을 막기 위한 조치다. 그런데 이 Python 에이전트는 찾아낸 소수를 매번 문자열로 만들고 있었다.
mersenne_primes.append(str((1 << p) - 1))24번째 메르센 지수는 p=19937이고, 2^19937−1은 무려 6,002자리다. 그러니 소수를 24개 이상 요청하면 도구가 ValueError를 뱉었고, A2A 응답은 결과 대신 스택 트레이스 텍스트를 고스란히 실어 날랐다.
"text": "Exceeds the limit (4300 digits) for integer string conversion;
use sys.set_int_max_str_digits() to increase the limit"결국 벤치마크의 Python 칸은 N≥24 구간에서 구조적으로 데이터를 만들어낼 수 없었다. 더 어이없는 부분은, 이렇게 만든 문자열 리스트를 어디에도 쓰지 않았다는 것이다. 도구는 elapsed_time만 반환한다. 해법은 그저 str()을 지우는 것이었고, 덤으로 측정 구간에서 포매팅 작업이 빠지면서 Node와 Rust가 애초에 치르지 않던 비용까지 사라졌다(Go에도 val.String()이라는 똑같이 쓸모없는 호출이 있었다).
수정은 PR #1에서 이뤄졌다. 여기에 더해 벽시계 시간이라 단조 증가하지 않는 time.time()을 time.perf_counter()로 바꾸고, count=24 회귀 테스트도 추가했다.
수정 전후 N=24 행을 비교하면 이렇다.
| Node.js | Rust | Go | Python | |
|---|---|---|---|---|
| 수정 전 | 1633.01 ms | 812.57 ms | 1451.49 ms | N/A (크래시) |
| 수정 후 | 1616.13 ms | 824.10 ms | 1531.10 ms | 2425.9 ms |

하네스가 LLM의 문장을 읽어 데이터를 뽑고 있었다 #
Python 에이전트의 측정값은 두 군데로 돌아왔다. 하나는 도구 아티팩트 안의 구조화된 elapsed_time, 다른 하나는 모델이 내뱉은 산문이다. 그런데 하네스는 산문 쪽을 먼저 정규식으로 긁었다.
m = re.search(r"It took ([\d\.\-e]+) seconds", text)실제 실행에서 Gemini는 한 번은 *"Calculating the first 5 Mersenne primes took 4.9591064453125e-05 seconds."*라고 했고, 다음 번엔 *"The calculation took 2.40715261301375 seconds."*라고 답했다. 둘 다 "It took" 패턴과 맞지 않는다. 그런데도 모든 데이터가 살아남은 건, 폴백 로직이 우연히 구조화된 값을 파내준 덕분이었다. 기계가 소비할 측정값이 LLM의 그날그날 말투에 달려 있어서는 안 된다.
한편 Node와 Rust는 elapsed.toFixed(2) 밀리초로 값을 보고했다. 그래서 10µs 미만의 실행은 0.00ms가 되어 0.0으로 파싱됐고, 로그 스케일 차트에서 소리 없이 사라졌다.
수정은 PR #2에서 진행했다. 구조화된 아티팩트를 먼저 읽고 산문은 최후의 수단으로 돌렸으며, 타이밍 출력을 소수점 넷째 자리까지 확장했다.
에이전트가 자기가 계산한 개수를 속였다 #
지수 테이블에는 항목이 26개뿐인데, 두 직접 핸들러 에이전트는 요청받은 개수를 그대로 되뇌었다.
$ curl ... "Calculate the first 100 Mersenne primes"
node: "Found first 100 Mersenne primes in 4450.78ms." # 실제로는 26개 계산
rust: "Found first 100 Mersenne primes in 2160.45ms." # 실제로는 26개 계산수정은 PR #3에서 이뤄졌다. primes.length / primes.len()를 그대로 보고하도록 바꿨다.
차트가 서로 다른 두 세계를 나란히 놓고 비교했다 #
Python과 Go 요청은 Gemini 툴 콜링을 거치지만, Node와 Rust는 메시지에서 숫자만 정규식으로 뽑아 직접 계산한다. 그런데 RTT 차트는 네 언어를 "LLM/툴 콜링 포함" 하나의 비교로 묶어 보여줬다. 이 설명이 맞는 건 정확히 절반뿐이었다. 중앙값 RTT는 Rust 2.6ms, Node 4.6ms인 반면 Go는 약 1.6초, Python은 약 1.8초였다. 이 400배쯤 되는 격차는 언어 성능이 아니라 파이프라인 구조에서 나온 것이다.
수정은 PR #4에서 진행했다. 에이전트를 파이프라인별로 태깅하고, 차트를 직접 처리와 Gemini LLM 경유라는 별개 계열로 로그 스케일에 그렸다. 제목도 실제로 무엇을 비교하는지 밝히도록 고쳤다.

검증 과정에서 튀어나온 두 개 (개인적으로 가장 마음에 드는 버그들) #
수정 때문에 코드가 자기 벤치마크를 감당 못 할 만큼 빨라졌다. Go의 측정 구간에서 포매팅을 걷어내자 작은 N 구간의 실행이 1마이크로초 아래로 떨어졌다. 그러자 Go의 time.Duration이 836ns를 출력하기 시작했는데, 하네스 파서가 한 번도 본 적 없는 단위였다. 그 데이터는 조용히 N/A가 됐다. 파서에 분기를 하나 더 넣어 해결했다.
Gemini가 같은 작업을 반복하기를 거부했다. 하네스가 결정적인 contextId를 재사용했는데, ADK는 컨텍스트별 세션 기록을 유지한다. 재실행하자 모델은 도구를 호출하지 않고 *"이미 했는데요. 다시 할까요?"*라고 답했다. 데이터 세 개가 날아갔다. 이제 벤치마크 ID는 실행마다 고유하게 만든다.
결과 #
한 언어 칸이 통째로 크래시하고, 작은 N 값은 그릴 수 없는 0으로 사라지고, 재실행마다 데이터 두 개가 LLM의 인내심에 달려 있던 상태에서 시작해 결국 96개 데이터 전부를 채웠다. 버그 아홉 개, PR 네 개, 재현 스크립트를 돌린 오후 한나절. 모두 수정 전후 차트와 함께 저장소에 남겼다.
직접 재현해볼 수도 있다. 아래 이미지는 수정된 네 에이전트를 모두 빌드하고 전체 스윕을 실행한다.
docker run --rm -e GEMINI_API_KEY=your_key -v "$PWD/out:/out" xbill9/bugsmashGemini 에이전트를 만든다면 새겨둘 것 #
이 스위트는 ADK와 gemini-2.5-flash 툴 콜링 위에서 처음부터 끝까지 돌아간다. 저자가 값비싸게 얻은 두 가지 교훈은 이렇다. 기계가 출력을 소비할 때는 모델의 산문이 아니라 구조화된 도구 아티팩트를 읽어라. 그리고 세션 메모리가 결과를 바꾸길 원하는 게 아니라면, 독립적인 요청에 컨텍스트 ID를 재사용하지 마라.
저자는 이 작업의 디버깅·자동화 에이전트로 Claude Code를 썼다고 밝혔다. 각 버그를 재현하고, 수정과 회귀 테스트를 작성하고, 수정 전후 벤치마크 스윕을 돌리는 일을 맡겼으며, 위의 모든 버그와 수치와 PR은 저장소에서 실제로 확인할 수 있다고 덧붙였다.
이 글은 위 출처를 바탕으로 한국 독자를 위해 재작성한 기사입니다. 원문의 사실과 수치에 근거하며, 별도의 견해를 포함하지 않습니다.
