당신의 에이전트 성공률은 생존자만을 집계하고 있습니다
요약
AI 에이전트의 성공률 측정 시 타임아웃, 중단, 무한 루프 등으로 인해 결과가 기록되지 않은 실행들이 누락되어 성공률이 부풀려지는 '생존자 편향' 문제를 경고합니다. 정확한 성능 측정을 위해서는 완료된 실행뿐만 아니라 시작된 모든 실행을 분모에 포함해야 합니다.
핵심 포인트
- 타임아웃이나 중단된 실행은 통계적 분모에서 누락되어 성공률을 왜곡함
- 단순 성공/실패 비율이 아닌, 시작된 모든 실행을 기준으로 지표를 산출해야 함
- 결과가 기록되지 않는 '종결 판정 없는 실행'이 가장 위험한 지표임
- 에이브러햄 발드의 생존자 편향 사례를 통해 데이터 누락의 위험성 설명
당신의 에이전트 대시보드는 90%의 성공률을 나타냅니다. 그것은 틀렸습니다. 수학적 계산이 허술해서가 아닙니다. 어떤 실행(run)들을 집계에서 누락했느냐 때문에 틀린 것입니다. 타임아웃(timed out)되었거나, 중단(aborted)되었거나, 3시간이 지난 후에도 여전히 RUNNING 상태로 멈춰 있는 모든 실행은 조용히 분모(denominator)에서 빠져나갔습니다. FAILED된 실행은 정직합니다. 그것은 손을 들었고, 당신의 에러 로그(error logs)에 남아 있으며, 마땅히 있어야 할 위치에서 이미 수치를 끌어내리고 있습니다. 당신이 두려워해야 할 실행은 당신에게 아무것도 말해주지 않으러 돌아오지 않은 실행입니다.
이것이 생존자 편향 (survivorship bias)이며, 제가 살펴본 거의 모든 신뢰성 수치에 존재합니다.
요약 (TL;DR)
- 단순한 성공률은 승리 횟수를 "깔끔하게 통과(pass)하거나 실패(fail)한 실행"으로 나눕니다. 이 집합에는 타임아웃, 중단, 그리고 멈춰버린(hung) 실행들이 제외됩니다.
- 제외된 실행들은 분모에서 사라지므로, 보이지 않음으로써 성공률을 부풀립니다. 실행이 더 많이 사라질수록 지표는 더 좋아 보입니다.
- 해결책은 더 나은 에러 핸들링 (error handling)이 아닙니다.
FAILED된 실행은 이미 집계에 포함되어 있습니다. 위험한 실행은 종결 판정(terminal verdict) 자체가 아예 없는 실행입니다. - 한 줄 요약: 완료된 실행이 아니라, 시작된 실행을 집계하십시오. 아래의 합성 수치(synthetic numbers)를 적용하면 90.0%는 72.0%가 됩니다.
돌아온 비행기
1943년 미국 군대는 유럽에서 돌아오는 폭격기들을 살펴보고 어디에 가장 많은 피해를 입었는지 지도에 표시했습니다. 날개, 동체, 꼬리 부분이었습니다. 당연한 조치는 그 부위들에 장갑을 덧대는 것이었습니다. 통계 연구 그룹 (Statistical Research Group)의 통계학자 에이브러햄 발드 (Abraham Wald)는 반대로 주장했습니다. 엔진에 장갑을 두르라고 말이죠. 착륙한 비행기들 중 엔진에 구멍이 난 곳은 거의 없었기 때문입니다. 엔진에 피격된 비행기들은 그의 표본(sample)에 포함되지 않았습니다. 그것들은 측정되기 위해 집으로 돌아오지 못했습니다. 당신이 보지 못하는 손상이 바로 당신을 죽이는 손상입니다.
당신의 실행 장부 (run ledger)도 같은 형태를 띱니다. 당신은 집으로 돌아온 실행들만을 측정하고 있습니다.
수치가 잘못되는 방식
제가 본 대부분의 성공률 (success-rate) 관련 코드들은—제 코드도 포함하여—그 취지가 다음과 같습니다: SUCCEEDED의 개수를 가져와서, 이를 SUCCEEDED와 FAILED의 합으로 나누고, 100을 곱합니다. 깔끔합니다. 마치 시험 합격률처럼 읽힙니다. 문제는 "plus FAILED"라는 문구 속에 숨어 있습니다. 왜냐하면 그것이 분모 (denominator) 전체이기 때문입니다. 당신은 명확한 '예' 또는 명확한 '아니오'를 가지고 돌아온 실행들로 승률을 나누고 있는 것입니다.
수많은 실행들은 그 어느 쪽으로도 돌아오지 않습니다.
장시간 실행되는 워커 (worker)가 9,000번째 행에서 네트워크 연결이 끊겨 다시 보고하지 않을 수 있습니다. 실행이 벽시계 시간 제한 (wall-clock limit)에 걸려 플랫폼이 이를 TIMED_OUT으로 표시할 수도 있습니다. 누군가 멈춰버린 작업을 수동으로 종료할 수도 있습니다. 그리고 최악의 경우: 실행이 그냥 멈춰버리는 (hangs) 상황입니다. 종료 코드 (exit code)도 없고, 최종 상태 (terminal status)도 없으며, 14:02 이후로는 로그 라인이 없습니다. 결말이 기록되지 않았기 때문에 며칠이 지나도 여전히 RUNNING으로 표시됩니다.
이 중 그 어떤 것도 SUCCEEDED가 아닙니다. 또한 그 어떤 것도 FAILED가 아닙니다. 따라서 "SUCCEEDED를 SUCCEEDED와 FAILED의 합으로 나눈" 비율은 이들을 낮게 평가하지 않습니다. 이들을 질문에서 삭제해 버립니다. 분모는 줄어들고 비율은 올라갑니다. 비종료 상태의 불확실한 상태 (non-terminal limbo) 속으로 사라지는 실행이 많아질수록, 대시보드는 더 건강해 보입니다. 이 지표는 당신을 가장 두렵게 만들어야 할 바로 그 실패 모드 (failure mode)에 보상을 주고 있습니다.
해결책은 에러 핸들링 (error handling)이 아닙니다
이 부분은 제가 깨닫기까지 부끄러울 정도로 오랜 시간이 걸렸습니다. 저는 며칠 동안 에러 핸들링 (error handling)을 강화하는 데 시간을 보냈습니다. 더 엄격한 try/except 경계 설정, 백오프 (backoff)를 적용한 재시도 (retries), 더 깔끔한 FAILED 기록 생성 등 말입니다. 하지만 그 중 어느 것도 실제 수치를 변화시키지 못했습니다. 왜냐하면 FAILED는 결코 문제가 아니었기 때문입니다.
FAILED된 실행은 당신의 장부 (ledger)에서 정직한 시민입니다. 그것은 당신이 잡을 수 있는 예외 (exception)를 던졌습니다. 그것은 에러 로그에 있고, 알림 (alerts)에 있으며, 이미 분모 안에 포함되어 있습니다. 에러 핸들링을 다듬는 것은 이미 스스로를 보고하고 있는 실행들을 개선하는 것뿐입니다.
지표를 오염시키는 실행(run)들은 명확한 판정(verdict)이 없는 것들입니다. 타임아웃(Timed out), 중단(Aborted), 혹은 결코 해결되지 않는 과도기적 상태(transitional status)에 갇힌 경우들 말이죠. 이들은 아무런 에러도 던지지 않습니다. 왜냐하면 당신의 코드 관점에서는 아무 일도 일어나지 않았기 때문입니다. 프로세스가 그냥 존재하기를 멈춘 것뿐입니다. 최종 상태를 기록하지 못한 채 실행 도중 노드(node)가 죽어버린 워커(worker)를 try/except로 처리할 수는 없습니다. 기술적으로 여전히 "실행 중(running)"인 실행에 대해서는 스택 트레이스(stack trace)가 존재하지 않습니다. 따라서 버그는 당신의 핸들러(handler)에 있는 것이 아닙니다. 버그는 당신의 분모(denominator)에 있습니다.
두 개가 아닌 세 개의 버킷 (Three buckets, not two)
이미 이를 명명하고 있는 어휘를 빌려 쓰는 것이 도움이 됩니다. 우리 액터(actor)들이 실행되는 플랫폼인 Apify는 모든 액터 실행이 작은 고정 집합 중 하나의 상태를 가진다고 문서화하고 있으며, 이는 세 가지 종류로 그룹화됩니다 (해당 문서와 대조 완료, 링크는 끝에 있음):
- 초기 (Initial):
READY, "시작되었으나 아직 어떤 워커에도 할당되지 않음." - 과도기 (Transitional):
RUNNING,TIMING-OUT,ABORTING. 실행이 진행 중인 상태. - 종료 (Terminal):
SUCCEEDED,FAILED,TIMED-OUT,ABORTED. 실행이 완료된 상태.
그들의 문서는 이를 명확하게 설명합니다: 실행은 초기 상태에서 시작하여, 하나 이상의 과도기적 단계를 거쳐, 종료 상태 중 하나로 결론을 맺습니다. 그것이 전체 라이프사이클(lifecycle)입니다. 32개의 액터에서 발생한 2,190개의 프로덕션 실행에 걸친 우리 자신의 실행 장부(run ledgers)도 전적으로 이 어휘 체계 안에 존재합니다. Trustpilot 리뷰 스크래퍼(scraper) 하나만 해도 해당 테이블에 962개의 실행을 보유하고 있으며, 한 시간 동안 계속되는 크롤(crawl)과 같은 긴 실행들은 정확히 메모리 한계(memory ceiling) 및 타임아웃(timeout)과 아슬아슬하게 맞닿아 있는 실행들입니다. 이들은 TIMED_OUT으로 끝나거나 과도기적 상태에 끼어버릴 가능성이 가장 높습니다. 따라서 단순한(naive) 성공률 계산에서 조용히 누락되는 실행들은 바로 생존시키기 가장 어려웠던 실행들과 동일합니다. 지표는 작업이 가장 어려운 바로 그 지점에서 눈이 멀게 됩니다.
단순한 통과율(pass rate)은 저 종료 상태 중 두 가지만 사용하며, 나머지 두 개의 종료 상태와 모든 과도기적 실행을 버려버립니다. 세 개의 버킷을 '예' 또는 '아니오'로 평탄화(flattened)해버리는 것입니다.
직접 실행해 볼 수 있는 수치로 본 산술 (The arithmetic, on numbers you can run)
여기 아주 작은 스크립트가 있습니다. 임포트(import)도, 네트워크도, 무작위성(randomness)도, 시계(clock)도 없습니다. 실행 횟수가 담긴 딕셔너리와 이를 나누는 세 가지 방법이 있을 뿐입니다. 이 장부(ledger)는 메커니즘을 격리하기 위해 수동으로 구축된 합성 데이터이며, 특정 행위자(actor)에 대한 측정값이 아닙니다. 왜 이 구분이 중요한지는 나중에 다시 다루겠습니다.
"""
survivorship_success_rate.py - 당신의 에이전트 성공률은 판결을 보고할 수 있을 만큼 충분히 오래 살아남은
실행(runs)을 기준으로 측정됩니다.
...
실행 결과:
=== 실행 장부 (시작 기록을 작성한 모든 실행) ===
SUCCEEDED 36 (terminal)
FAILED 4 (terminal)
...
분자는 하나, succeeded = 36입니다. 분모는 세 가지입니다.
NAIVE(단순 방식)는 통과(pass)와 실패(fail)를 합쳐 나누며, 40 중 36으로 계산하여 90.0%라고 보고합니다. 이것이 대부분의 대시보드에서 큰 화면에 띄우는 숫자입니다.
TERMINAL(종료 기준 방식)은 네 가지 종료 상태(terminal statuses) 모두로 나누며, 48 중 36으로 계산하여 75.0%라고 보고합니다. 이는 타임아웃(timeout)과 중단(abort)이 발생하지 않은 척하는 것을 멈추는 순간 얻게 되는 수치입니다. 단지 에러(error)를 발생시킨 것뿐만 아니라, 어떤 방식으로든 나쁘게 끝난 모든 실행을 계산하는 것만으로도 15%p가 사라졌습니다.
HONEST(정직한 방식)는 시작된 모든 실행으로 나누며, 50 중 36으로 계산하여 72.0%라고 보고합니다. 마지막 3%p는 여전히 RUNNING 상태에 갇혀 있는 두 번의 실행 때문입니다. 그것들은 결코 해결되지 않았습니다. 그것들은 종료 기록(terminal record)을 전혀 가지고 있지 않으며, 제가 밤잠을 설치며 걱정하는 대상이기도 합니다. 왜냐하면 끝이 없는 실행은 아무도 지켜보지 않는 실행이기 때문입니다.
첫 번째 숫자와 마지막 숫자 사이에 18%p의 격차가 발생합니다. 성공 횟수는 같습니다. 장부도 같습니다. 변한 것은 오직 제가 무엇을 계산할 용의가 있었느냐뿐입니다.
이 방식이 작동하지 않는 지점
저는 프로그램 자체의 출력 결과에 한계를 명시했습니다. 왜냐하면 스스로를 과대포장하는 수정안은 그저 더 화려한 형태의 거짓 지표(lying metric)에 불과하기 때문입니다. 이 방식이 수행하지 않는 세 가지가 있습니다.
첫째, HONEST는 여전히 진실이 아닌 상한선(upper bound)일 뿐입니다. 이는 시작 기록(start record)을 작성하는 데 성공한 실행(run)들만 집계합니다. 첫 번째 로그 라인이 찍히기도 전에 종료된 실행, 스폰(spawn) 시 발생한 OOM(Out of Memory), 네트워크에서 이탈한 노드 등은 장부에 전혀 기록되지 않습니다. 행(row) 자체가 생성되지 않은 것입니다. 따라서 실제 성공률은 기껏해야 72.0%이며, 아마 그보다 더 낮을 것입니다. 기록되지 않은 것은 셀 수 없습니다.
둘째, SUCCEEDED는 맹목적인 믿음에 의존합니다. 종료 코드가 0이지만 빈 배열을 반환하거나 데이터셋의 절반만 반환하는 실행도 이 스크립트에서는 여전히 승리로 기록됩니다. 분모를 수정한다고 해서 성공의 정의가 수정되지는 않습니다. 그것은 별개의 관문이며, 저는 이 나머지 절반에 대해 이전에 글을 쓴 적이 있습니다. 즉, 실행이 통과하더라도 조용히 틀렸던 깨끗한 행처럼 쓰레기를 전달할 수 있다는 것입니다. 결과를 정직하게 집계하는 것과 그 결과가 실제로 좋았는지 판단하는 것은 서로 다른 두 가지 작업입니다.
셋째, 장부(ledger)가 합성적(synthetic)입니다. 90.0%에서 72.0%로의 급감은 벤치마크가 아닌 산술적 수치를 보여줄 뿐입니다. 여러분의 진짜 격차는 RUNNING 컬럼의 크기가 얼마냐에 달려 있습니다. 만약 실행이 멈추는(hang) 경우가 거의 없다면, 여러분의 단순하고(naive) 정직한(honest) 비율은 서로 비슷하게 유지될 것이며, 그것은 좋은 일입니다. 하지만 전환(transitional) 컬럼이 두껍다면, 여러분의 대시보드는 한 번도 측정해 본 적 없는 오차 범위만큼 벗어나 있게 됩니다.
이것은 데이터 품질의 문제가 아닙니다
이 내용을 위의 "조용히 틀렸던 깨끗한 행" 포스트와 나란히 분류하기는 쉽습니다. 하지만 이들은 같은 버그가 아닙니다. 전자는 단일 실행 내부의 값에 관한 문제입니다. 즉, 파싱은 잘 되었지만 여전히 쓰레기 값을 담고 있는 행, 예를 들어 별 5개 사이트에서 7점이라는 평점 같은 것입니다. 이번 문제는 한 단계 더 높습니다. 이는 개별 실행의 출력이 올바른지 여부에 대해서는 아무것도 말하지 않습니다. 대신 전체 모집단(population)에 걸쳐 실행을 어떻게 집계하느냐에 관한 문제입니다. 하나의 실행은 결점 없는 데이터로 성공할 수 있지만, 그 이웃한 실행이 침묵 속에서 멈춰버렸다면, 여러분의 집계 비율(aggregate rate)은 여전히 전체 함대(fleet)에 대해 잘못된 정보를 제공하고 있는 것입니다.
또한 이것은 평가(eval)의 문제도 아닙니다. 에이전트의 최종 답변에 대해 회귀 게이트(regression gate)를 작성할 때, 여러분은 루브릭(rubric)을 기준으로 단일 응답의 품질을 판단하는 것입니다. 이는 유용하고, 필요하며, 이 문제와는 직교(orthogonal)하는 별개의 문제입니다. 성공률(success rate)은 실행이 어떻게 끝났는지를 세는 것이지, 무엇을 만들어냈는지를 세는 것이 아닙니다. 여러분이 결점 없는 평가 스위트(eval suite)를 보유하고 있더라도, 생존자 편향(survivorship bias)으로 인해 여전히 부풀려진 성공률을 가질 수 있습니다. 왜냐하면 평가는 채점할 결과물을 반환한 실행(run)만을 보기 때문입니다. 한 단계 위에서 똑같은 사각지대가 발생하는 것입니다.
내가 변경한 것
실제 해결책은 거의 모욕적일 정도로 간단합니다. 분모(denominator)를 바꾸십시오. 완료된 실행이 아니라, 시작된 실행을 세십시오. 실행이 생성되는 즉시 실행 테이블(run table)에 행(row)이 추가된다면, 분모는 RUNNING 상태인 모든 것을 포함하여 단순히 그 행의 개수가 되면 됩니다. 끝입니다.
여기서 여러분께 드려야 할 주의 사항이 하나 있습니다. 90초 전에 시작되어 여전히 RUNNING 상태인 실행은 실패가 아니라, 단지 아직 끝나지 않은 것입니다. 해당 실행은 중도 절단(right-censored)된 것이지 유실된 것이 아닙니다. 실시간 스냅샷(live snapshot)에서 이를 실패로 계산하면, 진행 중인 정상적인 작업을 실패한 작업과 한데 묶어버림으로써 비율을 비관적인 방향으로 편향시키게 됩니다. 따라서 정직한 분모는 확정된 것에 대한 것입니다. 이미 종료된 윈도우(window)를 대상으로 계산하거나, 다음 단락의 규칙에 따라 과도기적 실행(transitional runs)에 연령 제한(age-gate)을 두십시오. 해당 임계값보다 짧은 실행은 아직 대기 중인 것이지 손실이 아닙니다. 위의 합성 장부(synthetic ledger)는 정의상 이 문제를 피하고 있습니다. 두 개의 RUNNING 행은 이미 오래전에 종료된 종류이기 때문입니다. 실시간 대시보드에서는 여러분이 직접 그 선을 그어야 합니다.
이와 관련하여 추가한 두 가지 사항은 지표 자체보다 더 가치 있는 것으로 밝혀졌습니다.
나는 과도기적 실행의 연령(age)에 대해 알림(alerting)을 보내기 시작했습니다. 중앙값 지속 시간(median duration)의 3배 동안 RUNNING 상태인 실행은 실행 중인 것이 아닙니다. 그것은 죽어 있으며, 죽었다는 사실을 숨기고 있는 것입니다. 이 알림은 성공률이 그동안 숨겨왔던 실행들을 직접 가리키기 때문에, 성공률이 잡아낸 것보다 더 많은 실제 문제들을 포착했습니다.
그리고 저는 대시보드의 성공률 옆에 분모를 배치했습니다. "312개의 종료된(terminal) 작업 중 94%"와 "시작된(started) 1,040개 작업 중 94%"는 매우 다른 문장이며, 이 두 가지를 모두 보여주면 그 격차를 무시하고 지나치는 것이 불가능해집니다. 시작된(started) 횟수와 종료된(terminal) 횟수가 서로 멀어질 때, 그 괴리가 바로 숫자로 명확히 기록된 여러분의 생존자 편향 세금(survivorship tax)입니다.
우리의 실행 중 멈춰버린(hang) 비율을 인용하지는 않겠습니다. 정직한 답변은, 이 글의 핵심 주제이기도 한, 꽤 오랜 기간 동안 제가 그것을 측정하지 않았다는 것이기 때문입니다. 여러분이 볼 수 없는 숫자가 바로 여러분을 위협하는 숫자입니다. Wald는 엔진을 보호했습니다. 완료된 것이 아니라, 시작된 것을 세십시오.
Aleksei Spinov 작성. 저는 프로덕션 스크래퍼와 에이전트를 운영하고 있으며, 현재 32개의 액터(actor)에 걸쳐 2,190개의 실행을 수행 중입니다. 여기에 사용된 코드는 표준 라이브러리(stdlib)만을 사용하였으며, 게시 전 실행 및 검증(python3 -I, 동일한 출력, assert 통과)을 마쳤습니다. 장부(ledger)의 수치는 합성 데이터이며 스크립트에 그렇게 표시되어 있습니다. AI 어시스턴트와 함께 초안을 작성하였고, 제가 직접 사실 확인 및 편집을 수행했습니다.
실행 장부(run ledger)에서 한 번에 하나씩 문제를 해결해 나가는 다음 분석을 위해 팔로우해 주세요. 댓글을 위한 진지한 질문: 여러분의 대시보드에서 실행이 실제로 종료된 지 한참 지났음에도 여전히 RUNNING으로 표시된 채 가장 오래 머물렀던 시간은 얼마였으며, 무엇 때문에 마침내 이를 알아차리게 되었나요? 모든 답글을 읽겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기