AI 에이전트가 200 OK를 신뢰할 때: 페이지 내용이 쓰레기인 경우의 기록
요약
AI 에이전트가 HTTP 200 응답을 실제 콘텐츠의 성공으로 오인하여 잘못된 계획을 세우는 문제를 다룹니다. Cloudflare 챌린지, SPA의 빈 껍데기, 잘린 본문 등 '가짜 200' 응답의 유형과 이를 방지하기 위한 도구 계약의 중요성을 설명합니다.
핵심 포인트
- HTTP 200 응답이 반드시 유효한 콘텐츠를 보장하지 않음
- Cloudflare 챌린지나 SPA 렌더링 미비로 인한 '가짜 성공' 주의
- 에이전트의 신뢰성을 높이기 위한 정교한 도구 계약(Tool contracts) 필요
- 데이터 무결성을 확인하기 위한 분기 처리 및 검증 로직 설계 권장
어제 저는 에이전트에게 web_fetch 도구를 제공했습니다. 이 도구는 한 페이지를 가져와서 200 상태 코드와 화면 가득한 텍스트를 반환했고, 에이전트는 이를 바탕으로 자신감 있게 계획을 세웠습니다. 하지만 그 텍스트는 Cloudflare의
VERDICT KIND URL
----------------------------------------------------------------
OK real https://example.com
...
이 여섯 개의 행은 모두 200이었습니다. 하나는 실제 콘텐츠였습니다. 나머지 다섯 개는 페치 도구 (fetch tool)에게는 괜찮아 보이지만, 하위 계획 (downstream plan)을 망쳐버리는 종류의 200이었습니다. 게이트 (gate)는 에이전트가 무조건 믿어야 하는 문자열 대신, 분기 처리를 할 수 있는 이름을 각 항목에 부여합니다.
저는 이것을 Python 3.13.5에서 실행했습니다. 제3자 임포트 (third-party imports)도, 네트워크도 사용하지 않았습니다. python3 fetch_sanity.py를 실행하면 동일한 바이트 (bytes)를 얻을 수 있습니다. 확인 결과, 연속된 두 번의 실행이 동일한 md5 해시 값을 가졌습니다. 이는 중요한 문제이며, 왜 그런지에 대해서는 나중에 다시 다루겠습니다.
"200이고 비어 있지 않음"이 잘못된 성공 확인 방식인 이유
도구 계약 (Tool contracts)은 생략을 통해 거짓말을 합니다. 페치 도구 (fetch tool)가 생각하는 성공은 보통 두 가지입니다: HTTP 상태 코드가 2xx이고, 본문 (body)이 비어 있지 않다는 것입니다. 본문이 쓸모없더라도 이 두 가지는 모두 참일 수 있습니다.
저는 운영 환경에서 스크레이퍼 (scrapers)를 실행합니다: 32개의 게시된 액터 (actors)에 걸쳐 대략 2,190회 실행되었으며, Trustpilot 관련 액터 하나에서만 962회 실행되었습니다. 저에게 가장 많은 디버깅 시간을 소모하게 만든 실패는 500 에러나 타임아웃 (timeout)이 아니었습니다. 그런 것들은 명확하게 드러나므로 잡아낼 수 있습니다. 그것은 페이지가 아닌 본문과 함께 돌아온 200이었습니다. 네 가지 형태가 반복해서 나타납니다:
- 챌린지 월 (The challenge wall). Cloudflare의 "Just a moment...", Akamai의 "Access Denied", 또는 일반적인 "verify you are human"과 같은 메시지들이 403이 아닌 상태 코드 200으로 제공됩니다. 바이트 데이터는 실제 HTML이지만, 그것은 당신이 원하는 페이지가 아닙니다.
- 빈 껍데기 (The empty shell). 싱글 페이지 애플리케이션 (SPA)가
<div id="root"></div>와 세 개의 스크립트 태그만을 전송합니다. 데이터는 브라우저에서 렌더링됩니다. 당신의 원시 페치 (raw fetch)는 뼈대만을 가져온 것입니다. (페치하기 전에 이를 예측하는 것은 별도의 포스팅 주제입니다; 아래 참조.) - 잘린 본문 (The truncated body). 크기 제한, 연결 끊김, 또는 느린 스트림으로 인해 40 KB 페이지 중 처음 8 KB만 가져오게 됩니다. 페이지처럼 보이지만 문장 중간에서 끝납니다.
- 말 그대로 빈 200 (The literal empty 200). 일부 서버는 차단되었거나 속도 제한 (rate-limited)이 걸린 요청에 대해
200상태 코드와 빈 본문""로 응답합니다. 저는 한 번 스크래퍼가 며칠 동안 빈 배열을 반환하는 것을 지켜본 적이 있는데, 이는 200과 아무것도 없는 상태로 돌아온 소프트 차단 (soft-blocked) 요청에 대해 아무도 문제를 제기하지 않았기 때문이었습니다.
이것이 일반적인 스크래퍼보다 상황을 더 악화시키는 에이전트적 반전 (agentic twist)입니다. 쓰레기 데이터를 받은 스크래퍼는 잘못된 행 (row)을 생성하고, 결국 사람이 그 테이블을 눈으로 확인하게 됩니다. 하지만 쓰레기 데이터를 받은 _에이전트 (agent)_는 인간의 개입 (human in the loop) 없이 그 데이터를 자신의 다음 결정(다른 도구 호출, 요약 작성, 사용자 응답 등)에 그대로 입력합니다. 침묵 속의 실패 (silent failure)가 증폭됩니다. 모든 계층이 보기에 페치가 _성공_했기 때문에, 잡을 수 있는 예외 (exception)도 없고 트리거할 재시도 (retry)도 없습니다.
해결책은 더 똑똑한 프롬프팅 (prompting)이 아닙니다. 모델이 추론하기 전에 침묵하는 200을 명시적인 판결로 전환하는 체크포인트 (checkpoint)를 만드는 것입니다.
게이트가 실제로 확인하는 것
게이트는 휴리스틱 (heuristic)이며, 저는 논리적으로 설명할 수 없는 영리한 방식보다는 차라리 제가 신뢰할 수 있는 투박한 방식을 제공하겠습니다. 게이트는 세 가지 사항을 순서대로 확인하며, 첫 번째 항목에 걸리면 즉시 중단합니다.
1. Soft-block markers (소프트 차단 마커). "이것은 페이지가 아니라 벽입니다"라는 의미를 갖는 짧은 문자열 목록입니다: just a moment..., enable javascript and cookies to continue, attention required, access denied, verify you are human, cf-ray, 그리고 캡차 (captcha) 제공업체 명칭들. 이 중 어느 것이라도 (대소문자 구분 없이) 일치하면, 상태 코드가 200일 때조차, 특히 상태 코드가 200일 때 BLOCKED 판정을 내립니다. 이것들은 실제 타겟으로부터 성공한 것처럼 위장하여 돌아오는 것을 제가 직접 목격한 문자열들입니다.
m = _BLOCK_RE.search(low)
if m:
return "BLOCKED", f"soft-block marker {m.group(0)!r} (status={status})"
2. Visible-text-to-markup ratio (가시 텍스트 대비 마크업 비율). <script>와 <style>을 제거하고, 남은 태그들을 모두 제거한 뒤, 전체 블롭 (blob) 크기 대비 남은 읽기 가능한 텍스트의 양을 측정합니다. 실제 기사는 대부분 단어로 이루어져 있습니다. 반면 빈 껍데기 (empty shell)는 대부분 마크업으로 이루어져 있어 비율이 0에 가깝습니다. 다음 두 가지 경우에 EMPTY_SHELL 판정을 내립니다: 본문(body)이 말 그대로 비어 있거나, 가시 텍스트가 약 200바이트 미만이면서 비율이 0.10 미만인 마크업인 경우입니다. 두 번째 조항에 주목하세요: 거의 모든 내용이 읽기 가능한 텍스트인 짧은 블롭(간결한 JSON 응답, 한 줄 메시지 등)은 비율이 높기 때문에 EMPTY_SHELL이 아닌 OK 상태를 유지합니다. 껍데기(shell) 판정은 구체적으로 "마크업은 많지만 단어는 거의 없는" 경우를 위한 것입니다.
3. Truncation (잘림). 만약 본문이 <html> 트리를 열었으나 닫지 않았거나, 태그 중간에서 끝난다면 내용이 잘린 것입니다. 판정은 TRUNCATED입니다. 사유 문자열에는 어디서 멈췄는지 확인할 수 있도록 마지막 40글자를 그대로 출력합니다. 예를 들어 …'most of the spend was on <sp'는 <span> 태그 내부에서 끊긴 본문을 의미합니다.
아무것도 걸리지 않았다면? OK. 에이전트가 진행해도 좋습니다.
이것이 결정 영역(decision surface)의 전부입니다. 이 시스템이 하지 않는 것들에 주목하십시오. 이 시스템은 어떤 필드의 값도 검증하지 않습니다. 체크섬 (checksum), 범위 (range), 필드 간 로직 등을 확인하지 않습니다. 이 fetch (가져오기)를 이전의 것과 비교하거나 시간에 따라 스키마 (schema)를 추적하지도 않습니다. 브라우저가 필요했는지 여부도 결정하지 않습니다. 이 시스템은 오직 단 하나의 질문, "이것이 페이지이기는 한가?"에만 답하고 물러납니다. 이러한 좁은 범위가 바로 이 시스템의 특징입니다.
임계값은 의도적으로 투박합니다
가시적인 텍스트 200 바이트. 0.10 미만의 비율. 이것들은 무엇인가에 맞춰진 수치가 아닙니다. 이 수치들은 "여기에 명백히 콘텐츠가 거의 없는가?"를 판단하기 위한 것이며, 이보다 높은 수치는 모두 OK로 간주됩니다. 여러분의 트래픽에 맞춰 조정하십시오. 얇지만 실제적인 <title>과 150바이트의 요약을 제공하는 사이트는 이 수치에서 EMPTY_SHELL을 트리거할 것이며, 이는 여러분에게 오탐(false alarm)이 될 수 있습니다. 하한선을 높이십시오. 핵심은 제 상수(constants)가 아닙니다. "이것이 사용 가능한 콘텐츠인가"라는 질문은 모델이 토큰을 소비하기 전, 이미 보유하고 있는 바이트(bytes)만으로도 답할 수 있다는 점입니다.
그리고 소프트 블록(soft-block) 목록은 _차단 목록(denylist)_이므로 결코 완전할 수 없습니다. 제가 본 적 없는 문구로 작성된 챌린지 페이지는 OK로 통과됩니다. 이 목록은 제가 여러 플릿(fleet)을 거치며 접했던 벤더들을 잡아내지만, 어떤 사이트가 내일 새로 구축할 커스텀 벽(custom wall)까지 잡아내지는 못할 것입니다. 이에 대한 자세한 내용은 실패 모드(failure modes)에서 다루겠습니다.
에이전트 루프(agent loop)에 연결하기
이 게이트(gate)는 순수 함수(pure function)이므로, 여러분의 도구(tool)가 반환되는 곳 어디든 바로 끼워 넣을 수 있습니다. 패턴은 다음과 같습니다: 가져오기(fetch), 게이트(gate), 분기(branch).
text, status = my_web_fetch(url) # 기존 도구
verdict, reason = sanity_check(text, url, status)
...
두 번째 분기가 핵심입니다. 모델에게 챌린지 페이지를 건네주고 그것을 알아차리기를 기대하는 대신, 모델에게 [fetch unusable: BLOCKED] 소프트 블록 마커 '잠시만 기다려 주세요...'를 건네는 것입니다. 이제 모델은 관찰(observation)이 실패했음을 인지하고 다음과 같은 합리적인 행동을 할 수 있습니다: 다른 소스를 시도하거나, 브라우저 기반 가져오기(browser-based fetch)로 격상하거나, 캡차(captcha) 화면을 자신 있게 요약하는 대신 페이지를 읽을 수 없다고 사용자에게 알리는 것입니다.
이것이 핵심 전략입니다: 침묵하는 성공을 말하는 실패로 전환하는 것입니다. 모델은 자신이 볼 수 있는 오류에 반응하는 데 능숙합니다. 하지만 아무도 말해주지 않은 오류를 알아차리는 데는 매우 서툽니다.
체인 내에서의 위치 (그리고 두 형제 단계)
이 게이트는 더 긴 파이프라인(pipeline) 중 하나의 체크포인트이며, 인접한 단계들과 혼동하기 쉽습니다. 따라서 그 경계는 다음과 같습니다.
만약 페이지를 가져오기(fetch) ‘전’에 해당 페이지가 껍데기(shell)로 돌아올지 여부를 예측하고 싶다면, 그것은 원시 응답 형태(raw response shape)를 읽는 별개의 도구가 필요합니다. 저는 페이지에 브라우저가 필요한지 알려주는 30줄짜리 프로브 (probe)를 작성했습니다. 그것은 fetch ‘전’에 실행됩니다. 반면 이것은 fetch ‘후’에 실행됩니다. 이것은 렌더러(renderer)를 교체하거나 fetch 방식을 결정하지 않고, 단지 에이전트(agent)에게 받은 블롭(blob)이 스켈레톤(skeleton)이라는 사실을 표시(flag)할 뿐입니다.
그리고 200 응답을 생성한 fetch 도구 자체는 제가 어제 만든 60줄짜리 MCP web_fetch 서버입니다. 해당 포스트는
- 제가 접해보지 못한 문구의 도전적인 페이지. 차단 목록(denylist)은 Cloudflare, Akamai, 일반적인 캡차(CAPTCHA) 등을 잡아냅니다. 하지만 독특한 텍스트를 사용하는 자체적인 "잠시만 기다려 주세요" 페이지는
OK로 통과됩니다. 매번 가져올(fetch) 때마다 토큰 비용이 발생하는 모델 기반 분류기(model-based classifier)를 사용하는 것 외에는 이를 깔끔하게 해결할 방법이 없는데, 이는 이 도구의 목적과 정반대되는 방식입니다. - 실제로 내용이 아주 짧은 페이지. JSON으로 렌더링된 120바이트짜리 API 에러, 간결한 상태 페이지, 짧은 문서stub 등은 당신이 요청한 내용이 정확히 그것일 때
EMPTY_SHELL로 오인될 수 있습니다. 비율 테스트(ratio test)로는 "빈 껍데기(empty shell)"와 "작지만 실제 내용이 있는 페이지"를 구분할 수 없습니다. 하한선(floor)을 조정하거나, 짧은 본문을 반환한다고 알고 있는 엔드포인트에 대해서는 비율 검사를 건너뛰어야 합니다. - 태그가 우연히 닫힌 채로 잘린 본문. 만약 잘린 지점이
</html>바로 뒤라면(드물지만 버퍼링된 프록시(buffered proxy) 환경에서는 가능합니다), 절단 검사(truncation check)를 놓치게 됩니다. 길이와Content-Length를 비교하면 이를 잡아낼 수 있겠지만, 제 fetch 도구는 항상 신뢰할 수 있는Content-Length를 가지고 있지는 않기에, 어설프게 구현하기보다는 제외했습니다. - 마크업이 전혀 없는 상태로 잘린 본문. 절단 검사는 HTML에서만 작동합니다(닫히지 않은
<html>트리나 태그 중간의 잘림을 찾습니다). 배열 중간에서 잘린 JSON 또는 일반 텍스트(plain-text) 응답은 읽을 태그가 없으므로OK로 통과됩니다. JSON 엔드포인트의 경우, 이를try문 안의json.loads와 결합하여 파싱 실패를 직접TRUNCATED로 처리하십시오. - 403 오류가 발생하는 차단된 페이지. 이 게이트(gate)는 200 형태의 거짓말을 위한 것입니다. 만약 당신의 fetch 도구가 이미 4xx/5xx 에러에서 예외를 발생시킨다면(제 도구는 그렇습니다), 해당 에러들은 여기에 도달하지 않으며 이는 올바른 동작입니다. 이 게이트는 상태 확인(status check) 단계에서 그냥 통과시켜 버리는 실패 사례들을 잡아내기 위해 존재합니다.
마지막 지점이 바로 설계의 경계선입니다. 이 게이트는 당신의 상태 처리(status handling)를 대체하는 것이 아닙니다. 상태 처리가 구조적으로 인지하지 못하는 부류, 즉 내용이 없는 성공 코드(success codes)를 잡아내는 역할을 합니다.
왜 "네트워크 없음"이 각주 수준이 아닌가
이 게이트(gate)는 의도적으로 네트워크 호출을 전혀 수행하지 않습니다. sanity_check(text, url, status)는 입력값에 대한 순수 함수 (pure function)입니다. 즉, 동일한 데이터 덩어리(blob)가 들어가면 동일한 판결이 나옵니다. 이를 통해 세 가지 이점을 얻을 수 있습니다. 첫째, 테스트가 특정 판결에 고정되어 결코 불안정하게 작동(flake)하지 않습니다. 둘째, 위의 출력 결과는 바이트 단위로 동일하게 재현 가능합니다 (제가 md5를 확인했습니다). 셋째, 차단을 "확인"하기 위해 라이브 안티-봇(anti-bot) 사이트에 호출을 보내는 게이트는 지연 시간(latency), 데이터 송출(egress), 그리고 추가적인 실패 요인을 발생시킵니다. 데이터 덩어리는 이미 도착했습니다. 이를 판단하는 데 필요한 모든 것은 이미 바이트 안에 들어 있습니다.
제가 배포하는 모든 체크포인트(checkpoint)에 적용하는 것과 동일한 원칙입니다. 브라우저 탐색(browser probe), 스키마 카나리(schema canary), 필드 무결성 검사(field sanity checks)는 모두 이미 보유하고 있는 데이터에 대한 순수 함수입니다. 이것이 다음 작업자가 제 말을 믿는 대신, 제가 얻은 것과 정확히 일치하는 결과를 다시 얻을 수 있게 해주는 핵심입니다.
월요일에 제가 할 일
결과가 관찰값(observation)이 되기 전, fetch 도구가 반환한 직후에 바로 게이트를 배치하십시오.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기