본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 18. 03:35

빈 HTTP 200 OK 응답 하나가 에이전트의 10단계 중 5단계를 오염시키다

요약

HTTP 200 응답이지만 본문이 비어 있는 경우, AI 에이전트가 잘못된 데이터를 사실로 인지하여 이후 단계들을 오염시키는 문제를 다룹니다. 단순한 입력 검증을 넘어, 데이터의 출처를 격리하고 확인된 사실만 재사용하도록 하는 '출처 격리(provenance quarantine)' 전략을 해결책으로 제시합니다.

핵심 포인트

  • 빈 HTTP 200 응답이 에이전트의 컨텍스트를 오염시켜 잘못된 답변을 생성함
  • 단순 입력 검증(Input Gate)만으로는 이미 유입된 잘못된 데이터의 확산을 막을 수 없음
  • 해결책으로 각 사실에 verdict, source_step, confirmed 정보를 포함하는 출처 격리 제안
  • 검증된 사실 위에서만 다음 단계를 구축함으로써 오염 범위를 0으로 차단 가능

도구 호출(tool call) 하나가 빈 본문(empty body)과 함께 HTTP 200을 반환했습니다. 제 에이전트는 어깨를 으쓱하더니, 자리 표시용 가격(placeholder price)을 적어 넣고 다음으로 넘어갔습니다. 아무것도 충돌하지 않았습니다. 예외(exception)도, 빨간색 로그 라인도 없었습니다.

열 단계가 지난 후, 에이전트가 사용자에게 전달한 답변은 그 가격을 바탕으로 만들어졌습니다. 그 사이의 다른 네 단계 역시 마찬가지였습니다. 단 하나의 빈 200 응답이, 누군가 확인하기 전에 10단계 중 5단계를 조용히 오염시켰습니다.

이것은 잘못된 페치(fetch)를 _허용할지_에 대한 이야기가 아닙니다. 그 이야기는 이미 썼습니다. 이것은 잘못된 데이터가 유입된 _이후_에 벌어지는 일에 관한 것입니다.

요약 (TL;DR)

  • 하나의 도구가 사용할 수 있는 본문이 없는 HTTP 200을 반환했습니다. 에이전트는 그럼에도 불구하고 자리 표시용 "사실(fact)"을 기록했습니다. 그 사실은 이후에 아무것도 다시 페치(re-fetch)하지 않은 단계들에 의해 **재사용(reused)**되었습니다.
  • 합성된 10단계 계획에서, 컨텍스트에 이미 존재하는 사실을 신뢰하는 순진한(naive) 에이전트는 사용자에게 전달할 최종 답변을 포함하여 10단계 중 5단계가 오염되는 결과를 초래했습니다. 피해 범위(Blast radius): 5.
  • 해결책은 "페치를 더 엄격하게 검증하라"가 아닙니다. 그것은 **출처 격리(provenance quarantine)**입니다. 모든 사실은 {verdict, source_step, confirmed}를 포함해야 하며, 각 단계는 단순히 상속받은 사실이 아니라 자신의 체인 내에서 실제 OK 페치에 의해 확인된(confirmed) 사실 위에서만 구축되어야 합니다.
  • 이 게이트(gate)를 적용하자, 동일한 계획의 피해 범위가 0으로 떨어졌습니다. 오염은 입구(s1)에서 차단되었고, 깨끗한 브랜치는 여전히 살아남았습니다.
  • 아래 코드는 표준 라이브러리(stdlib)만 사용하며, 결정론적(deterministic)이고, 네트워크를 전혀 사용하지 않습니다. 복사해서 실행해 보시고 피해 범위가 0이 되는 것을 확인하세요. 전체 표준 출력(stdout)의 MD5 값이 스크립트에 포함되어 있으므로, 저와 동일한 바이트를 얻었는지 확인할 수 있습니다.

이것은 입력 게이트가 아닙니다 (그리고 이것이 중요한 이유)

5일 전 저는 다른 문제에 대해 글을 썼습니다: 당신의 에이전트는 200 OK를 신뢰하며, 저는 페이지가 쓰레기였던 빈도를 기록했습니다. 그 글은 '문(door)'에 관한 것이었습니다. 즉, 새로운 데이터를 가져오는 즉시 OK / BLOCKED / EMPTY_SHELL / TRUNCATED 중 하나로 태깅하는 40줄짜리 sanity_check (무결성 검사)에 관한 것이었죠. 당시 실행 결과, 6개의 블롭(blob) 중 1개만이 사용 가능한 콘텐츠였으며, 나머지는 차단 벽이거나 빈 껍데기였습니다. 게이트(gate)의 유일한 임무는 잘못된 블롭이 콘텐츠로 읽히는 것을 막는 것입니다.

여기서 불편한 부분이 있습니다. 만약 게이트가 하나를 놓쳤다고 가정해 봅시다. 혹은 게이트가 없어서 에이전트가 단순히 플레이스홀더(placeholder)를 기록했다고 가정해 봅시다. 이제 잘못된 사실은 문을 통과해 버렸습니다. 그것은 컨텍스트(context) 내에서 실제 데이터와 구별할 수 없는 일반적인 항목이 됩니다. 그리고 이후 가격을 확인하려는 모든 단계는 기꺼이 그 데이터를 재사용할 것입니다.

그것이 바로 간극(gap)입니다. 입력 검증기(input validator)는

s1 --> s3 --> s6
s1 --> s5 --> s8 --> s10

s1이 유일한 잘못된 fetch (데이터 가져오기)입니다. 하지만 s3, s5, s6, s8, 그리고 s10은 모두 직접적으로 또는 전이적으로(transitively) s1으로부터 상속받습니다. 그 경로에 있는 그 누구도 "잠깐, 그 가격이 정말로 가져와진 게 맞나?"라고 다시 묻지 않습니다. 그들은 단지 문맥 속의 사실을 보고 그것을 신뢰할 뿐입니다. 이 placeholder (자리 표시자)는 사용자가 읽게 될 최종 답변인 s10까지 그대로 타고 올라갑니다.

한편, s2 (통화)와 s2를 재사용하는 s9s1에 전혀 닿지 않았습니다. 이들은 깨끗합니다. 이 세부 사항은 나중에 중요해지는데, 왜냐하면 "모든 fetch의 하류(downstream)를 그냥 차단하는" 방식의 수정은 무용지물이기 때문입니다. 그것은 깨끗한 분기(branch)까지 죽여버릴 것입니다.

나는 폭발 반경(blast radius)을 측정했다

나는 이 메커니즘을 증명하는 가장 작은 것을 만들었습니다. 결정론적(deterministic)이며 표준 라이브러리(stdlib)만 사용하는 스크립트입니다. 네트워크도, 무작위성(randomness)도, 시계(clock)도 없습니다. "도구 결과(tool results)"는 인라인 픽스처(inline fixture)로 처리되어, 당신의 컴퓨터에서도 내 컴퓨터와 동일한 출력이 나옵니다. 여기 직접 타이핑하지 않고 복사해서 붙여넣은 실제 stdout (표준 출력)입니다:

=== 하나의 EMPTY_SHELL fetch, 두 개의 에이전트, 동일한 10단계 계획 ===
계획 단계: 10 | 오염 유입 지점: s1 (HTTP 200, 빈 본문)

...

단순한(naive) 블록을 읽어보십시오. 6개의 단계가 오염을 전달합니다 — s1 s3 s5 s6 s8 s10. 그중 하나(s1)는 원래의 잘못된 fetch이며, 나머지 다섯 개는 _하류 재사용(downstream reuse)_입니다. 그것이 바로 폭발 반경입니다: 5. 그리고 마지막으로 오염된 단계는 최종 답변인 s10입니다. 사용자는 한 번도 fetch된 적 없는 숫자를 받게 되었습니다.

이제 게이트 블록(gate block)입니다. 동일한 계획, 동일한 오염, 폭발 반경은 0입니다. 잘못된 fetch는 s1에서 격리되며, 이에 의존했던 모든 재사용 단계(s3 s5 s6 s8 s10)는 이를 바탕으로 구축되는 것이 차단됩니다. s10은 더 이상 오염되지 않는데, placeholder를 상속받는 것이 허용되지 않았기 때문입니다.

미리 정직한 주의 사항을 하나 말씀드리자면, 제가 읽는 입장이라도 이 점을 알고 싶을 것이기에 말씀드립니다. 이 계획은 픽스처(fixture)이며, 실제 LangChain 실행을 캡처한 것이 아닙니다. 나는 상속의 깨끗하고 최소한의 예시가 되도록 재사용 엣지(reuse edges)를 수동으로 구축했습니다. 메커니즘 — 즉, 재확인 없는 재사용이 잘못된 사실을 퍼뜨린다는 점 — 은 실제이며 나는 그것이 발생하는 것을 목격했습니다. 구체적인 10단계 형태는 그것을 모델링한 것이지, 스크린샷이 아닙니다.

왜 "빈 200"이 허수아비 공격이 아닌가

본문이 비어 있는 200 OK 응답이 제 주장을 뒷받침하기 위해 제가 만들어낸 드문 예외 사례(edge case)라고 생각할 수도 있습니다. 하지만 그렇지 않습니다. 제가 공개한 Apify actor들을 통해 평생 동안 **2,190회의 프로덕션 실행(production runs)**을 기록했습니다. 이는 실제 사이트를 대상으로 한 실제 작업들입니다. Trustpilot 리뷰 스크래퍼(scraper) 하나만 해도 그중 962회를 차지합니다. 이 작업 과정에서, fetch 결과가 200으로 돌아오지만 본문(body)이 비어 있거나, 챌린지 월(challenge wall)이거나, 절반만 로드된 셸(shell) 상태인 경우는 이례적인 일이 아닙니다. 그저 평범한 일상(regular Tuesday)일 뿐입니다. 위에서 링크한 입력 게이트(input-gate) 실행 사례에서는, 샘플링된 6개의 블롭(blob) 중 실제로 사용 가능한 콘텐츠는 단 1개뿐이었습니다. 나머지는 페이지가 있는 것처럼 거짓말을 하는 200 응답들이었습니다.

따라서 진입점(entry point)은 평범합니다. 위험한 것은 그 진입점을 가지고 계획(plan)이 무엇을 하느냐입니다.

해결책: 출처 격리 (provenance quarantine)

순진한 에이전트(naive agent)는 한 가지 규칙을 따릅니다: 만약 어떤 사실(fact)이 컨텍스트(context)에 있다면, 그것을 사용할 수 있다. 이 규칙이 바로 버그입니다. 이 규칙은 "값을 가지고 있다"와 "확인된 값을 가지고 있다"를 동일한 것으로 취급합니다.

게이트(gate)는 모든 사실에 세 가지 필드를 추가합니다: {verdict, source_step, confirmed}. 그리고 순진한 규칙을 대신하여 하나의 규칙이 적용됩니다:

단계(step)는 그 단계가 의존하는 모든 사실이 confirmed인 경우에만 그 사실을 바탕으로 구축될 수 있다 — 여기서 confirmed란, 검증되지 않은 상류(upstream)로부터 상속된 것이 아니라, 동일한 체인 내의 실제 OK fetch로부터 왔음을 의미한다.

오염된 fetch (verdict = EMPTY_SHELL)는 입구에서 confirmed = False로 표시됩니다. 이를 재사용하려는 모든 시도는 검사를 통과하지 못하고 차단됩니다. 오염은 전파될 수 없습니다. 왜냐하면 전파를 위해서는 사실을 상속받아야 하는데, 확인되지 않은(unconfirmed) 사실은 상속받을 수 없기 때문입니다.

다음은 핵심 로직인 confirmed 맵과 실제 작업을 수행하는 단일 분기(branch)입니다:

def run_quarantine(plan):
    by_id = {s[0]: s for s in plan}
    confirmed = {}      # step_id -> bool (fact usable downstream?)
...

all(confirmed.get(d, False) for d in deps) 이 한 줄이 방어의 전부입니다. 재사용 단계(reuse step)는 모든 의존성(dependency)이 이미 확인(confirmed)되었을 때만 자신을 confirmed = True로 설정할 수 있습니다. 체인 내 어디에서든 확인되지 않은 조상(ancestor)이 하나라도 있다면, 해당 단계는 쓰레기 데이터를 조용히 상속받는 대신 차단됩니다.

이제 제가 가장 중요하게 생각하는 부분입니다. 출력을 다시 보세요. 게이트(gate)는 오염된 체인인 s3 s5 s6 s8 s10을 차단했고, s2s9는 그대로 두었습니다. 깨끗한 통화(currency) 분기(branch)는 살아남았습니다. 이 게이트는 아무것도 재사용하지 못하게 막는 대형 해머가 아닙니다. 잘못된 가져오기(fetch)로 거슬러 올라가는 체인을 정밀하게 격리합니다. 만약

그리고 이를 무시하는 비용은 예산 항목에서 나타납니다. Gartner는 2025년 6월 25일 보도 자료에서 2027년 말까지 에이전트형 AI(agentic AI) 프로젝트의 40% 이상이 취소될 것으로 예측하며, 그 이유로 높아지는 비용, 불분명한 가치, 그리고 _부적절한 위험 통제(inadequate risk controls)_를 언급했습니다. Gartner의 선임 디렉터 애널리스트인 Anushree Verma는 현재 에이전트형 프로젝트 대부분을

그래서 제가 정말로 결론을 내리지 못한 부분이 있고, 여러분이 이를 어떻게 처리하는지 듣고 싶습니다: 여러 단계로 구성된 에이전트(multi-step agents)를 사용할 때, 컨텍스트(context)를 통해 사실의 출처(provenance)를 실제로 추적하나요? 그리고 확인되지 않은 사실의 재사용을 차단하나요, 아니면 그냥 로그를 남기고 요행을 바라나요? 오염된 사실을 로그에 남기면서도 여전히 s10이 사용하도록 방치하는 것은 해결책이 아닙니다. 그것은 사후 분석(postmortem)을 위한 종이 흔적(paper trail)일 뿐입니다.

만약 LLM이 값을 새로운 토큰으로 바꾸어 표현(paraphrasing)하더라도 유지되는 진정한 출처 추적(provenance tracking) 시스템을 구축하셨다면, 그 사례를 꼭 듣고 싶습니다. 그 부분은 제가 아직 해결하지 못한 지점이기 때문입니다.

저는 실제 실행 데이터(numbers from real runs)를 바탕으로, 재현 가능한 에이전트 신뢰성 실패 사례를 한 번에 하나씩 작성하고 있습니다. 다음 글을 위해 팔로우해 주세요. 그리고 여러분이 겪었던 최악의 "에이전트가 확신에 차서 틀렸는데 왜 그런지 알 수 없었던" 버그가 무엇인지 알려주세요. 모든 댓글을 읽고 있습니다.

전체 스크립트

표준 라이브러리(Stdlib)만 사용하며, 결정론적(deterministic)이고 네트워크를 사용하지 않습니다. python3 -I poison_propagation.py로 실행하세요. 출력되는 MD5를 통해 바이트 단위로 동일한(byte-identical) 출력을 얻었는지 확인할 수 있습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0