LLM 에이전트에게 클라우드 드라이브 쓰기 권한을 주었습니다. 세 가지 버그를 통해 제약 방법을 배웠습니다.
요약
LLM 에이전트에게 클라우드 드라이브 쓰기 권한을 부여할 때 발생하는 보안 및 제어 문제를 다룹니다. Mediary Scout 프로젝트를 통해 에이전트의 제안과 결정론적 워크플로를 분리하여 폭발 반경을 최소화하는 설계 방식을 설명합니다.
핵심 포인트
- 에이전트의 제안과 실제 실행을 분리하여 폭발 반경 최소화
- 결정론적 코드를 통해 되돌릴 수 없는 작업과 검사 제어
- 에이전트의 과도한 루프 및 중복 작업으로 인한 API 제한 문제 사례
- 샌드박스화된 에이전트와 도구 세트 설계의 중요성
저는 무엇이 존재해야 하는지와 실제로 무엇이 존재하는지를 구분할 줄 아는 미디어 라이브러리를 원했습니다. 제가 시도했던 대부분의 자동화 도구들은 한쪽 측면만을 선택했습니다. 어떤 도구들은 검색은 잘하지만 이미 가지고 있는 것은 추적하지 못합니다. 다른 도구들은 파일을 이동시키고 이동이 성공했다고 가정해 버립니다. 저는 그 두 가지 사이의 간극을 소프트웨어가 작동하는 대상으로 삼고 싶었습니다.
그래서 저는 Mediary Scout를 만들었습니다. 영화나 쇼의 이름을 입력하면, LLM 에이전트가 인덱서(indexers)를 검색하고, 가장 적합한 결과물을 사용자의 클라우드 드라이브로 전송한 다음, 드라이브를 다시 읽어 무엇이 도착했고 무엇이 여전히 누락되었는지 확인합니다. 이 프로그램은 셀프 호스팅(self-hosted) 방식으로 실행됩니다. 사용자가 직접 드라이브, 모델, 메타데이터 키를 가져옵니다. 단순히 더블 클릭해서 실행하고 싶다면 Mac 및 Windows용 데스크톱 빌드가 있으며, 먼저 하나의 수집 과정이 어떻게 진행되는지 보고 싶다면 읽기 전용 데모를 확인할 수 있습니다.
현재 이 프로그램이 지원하는 드라이브는 중국 클라우드 저장소(115, Quark, GuangYaPan)입니다. 이 세부 사항은 이 글의 나머지 내용과는 상관이 없습니다. 진짜 노력이 필요했던 부분은 다른 것이었습니다. LLM에게 파일을 이동하고 삭제하는 도구(tools)를 쥐여주고, 그것이 파일로 멍청한 짓을 하지 못하도록 막는 일이었습니다. 세 가지 버그를 통해 저는 현재 제가 믿고 있는 것들에 대해 대부분을 배웠습니다.
구조 (The shape of the thing)
웹 앱은 거의 흥미로운 일을 하지 않습니다. Postgres 큐에 행(row) 하나를 작성하고 반환할 뿐입니다. 오래 실행되는 워커(worker)가 해당 행을 가져와 샌드박스화된 에이전트(sandboxed agent)를 시작합니다.
에이전트는 작은 도구 세트를 부여받습니다: 리소스 검색, 후보 전송, 디렉토리 목록 나열, 파일을 시즌 폴더로 이동, 에피소드를 획득됨으로 표시하기 등입니다. 모든 도구는 실제 부수 효과(side effect)를 제어하는 결정론적 워크플로(deterministic workflow)를 통해 실행됩니다. 에이전트는 제안(propose)합니다. 워크플로는 그 제안이 허용되는지 결정하고, 실행하며, 다시 세상의 상태를 읽어옵니다.
그 분리는 설계의 핵심입니다. 모델은 제가 완전히 예측할 수 없는 부분이기 때문에, 가능한 한 가장 작은 폭발 반경 (blast radius)을 갖도록 설정합니다. 모델 주변의 결정론적 코드 (deterministic code)가 모든 되돌릴 수 없는 작업과 모든 검사를 담당합니다. 제가 그 분리를 위반했을 때, 문제들이 발생했습니다. 다음과 같은 순서로 문제가 발생했습니다.
버그 1: 에이전트가 16번 검색하여 한 시즌의 복사본 11개를 선택함
12개의 에피소드로 구성된 한 시즌은 기껏해야 하나의 완벽한 팩만 있으면 됩니다. 하지만 제 에이전트는 이를 찾기 위해 16번의 개별 검색을 수행한 후, 11개의 중복되는 시즌 팩으로 이를 채우기로 결정했습니다. 한 번의 계획 단계 (planning pass)에서 모델 루프 (model loop) 내부에서만 6.8분이 소요되었습니다.
그 후 전송 루프 (transfer loop)는 중복 제거 (deduplication)가 실행되기 전에 11개의 팩을 모두 받으려고 시도했습니다. 115는 정확히 이런 식의 과도한 요청을 막기 위해 존재하는 작업당 API 예산 (API budget)을 가지고 있습니다. 실행 과정에서 이 제한에 걸려 (PAN115_RATE_LIMIT) 중단되었습니다. 에이전트가 너무 철저하게 행동하려 했던 탓에 전체 획득 과정이 실패한 것입니다.
제 프롬프트에는 나중에 단계에서 중복을 제거할 것이므로 중복이 발생해도 안전하다는 문구가 있었습니다. 그 문구는 거짓이었습니다. 중복 제거는 예산을 소진해버린 전송 작업이 완료된 후에야 실행되었습니다. 제가 모델에게 약속했던 정리 작업은 실행될 기회조차 얻지 못했습니다.
저는 절제를 요구하도록 프롬프트를 수정하는 데 오후 시간을 보냈습니다. 하지만 모델은 그 모든 것을 무시했습니다. 모델에게 경제적으로 행동하라고 요구하는 것은 물에게 덜 젖어 있으라고 요구하는 것만큼이나 효과가 없습니다.
해결책은 영어가 아닌 결정론적 코드 (deterministic code)에 있었습니다. 탐욕적 집합 커버 함수 (greedy set-cover function, trimToMinimalCoveringCandidates)가 모든 원하는 에피소드를 포함하는 최소한의 팩을 계산하고, 전송이 일어나기 전에 나머지를 제거합니다. 두 번째 게이트는 별개의 검색을 8개로 제한하고 도구 경계 (tool boundary)에서 동일한 쿼리를 중복 제거하여, 동일한 키워드가 제공자에게 단 한 번만 도달할 수 있도록 합니다. 모델은 16번의 검색을 요청할 수 있습니다. 하지만 실제로는 8번만 수행되며, 중복된 요청은 캐시된 스냅샷 (cached snapshots)으로 반환됩니다.
이 교훈은 제 프로젝트를 넘어 일반화될 수 있습니다. 만약 에이전트가 숫자(검색 횟수, 선택할 항목 수, 호출 횟수 등)를 생성한다면, 그 숫자에 결정론적인 상한선 (deterministic ceiling)을 설정하십시오. 모델이 스스로를 제한할 것이라고 믿지 마시고, 다음 단계가 난장판을 정리해 줄 것이라고 믿지도 마십시오. 게이트 (gate)는 모델과 되돌릴 수 없는 동작 (irreversible action) 사이에 위치해야 합니다.
버그 2: 계속 자동화하려고 시도했던 커버리지 확인 (coverage check)
커버리지 (Coverage)란 내가 어떤 에피소드들을 가지고 있는가에 대한 문제입니다. 정직한 답변을 얻으려면 드라이브 내의 실제 파일들을 확인해야 합니다. 저는 대신 이를 기계적으로 계산하려고 계속 시도했고, 제 자신의 설계 노트는 계속해서 그만두라고 말하고 있었습니다.
저는 파일이 존재하는지 "확인"하기 위해 특정 표시(mark) 후에 드라이브를 다시 읽는 (re-read) 로직을 작성했습니다. 제목에서 에피소드 번호를 추측하는 파일명 파서 (filename parser)를 작성했습니다. 디렉토리에 어떤 비디오 파일이라도 있으면 영화를 획득한 것으로 간주하는 확인 로직을 작성했습니다. 각각의 방식은 합리적으로 느껴졌습니다. 하지만 각각의 방식은 에이전트의 판단이 있어야 할 자리에 기계적인 추측을 앉혀 놓았습니다.
제가 정착한 원칙은 엄격한 선을 긋는 것입니다. 에이전트는 파일을 이동시키고 제자리에 평탄화 (flatten)한 후에, 실제 파일들을 검사하여 커버리지를 결정합니다. 그런 다음 자신이 어떤 에피소드들을 확보했는지 선언합니다. 그 선언은 파일 ID도 없고 숨겨진 재읽기 (re-read)도 없는 단순한 진술입니다. 시스템은 이를 기록하고 다음으로 넘어갑니다. 시스템은 에이전트를 재검증하기 위해 스스로 파일을 세지 않으며, 진실을 결정하기 위해 파일명을 파싱하지도 않습니다.
장부 기록 (bookkeeping) 측면도 마찬가지로 엄격합니다. 저는 에이전트가 표시한 것을 가집니다. 메타데이터가 방영되었다고 말하는 것을 가져야 합니다. 누락된 세트는 이 두 값 사이의 차이입니다. 예정된 스윕 (scheduled sweep)은 그 차이가 불완전하다고 말하는 쇼에 대해서만 에이전트를 깨웁니다. 수천 개의 완료된 쇼는 스캔할 대상이 없기 때문에 결코 스캔되지 않습니다.
전달하고자 하는 핵심은 이것입니다: 모델이 지저분한 현실 세계의 질문을 판단하게 하되, 모델 자신의 서술(narration)이 아닌 실제 세계를 기준으로 판단하게 하십시오. 결정론적인 장부 기록(deterministic bookkeeping)은 별도로 분리하여 단순하게 유지하십시오. 이 두 가지 책임이 서로 섞이기 시작하는 순간 문제가 발생합니다.
버그 3: 세 번의 Pull Request(PR) 동안 아무것도 렌더링하지 못한 진행 표시줄
이 버그는 제 자존심에 가장 큰 상처를 남겼습니다.
느린 모델을 사용하는 사용자들은 데이터 획득(acquisition) 중에 인라인 진행 표시줄(inline progress bar)이 비어 있는 것처럼 보인다고 보고했습니다. 저는 PR을 올렸습니다. 표시줄의 단계 매핑(phase mapping) 중 한 단계가 잘못된 대역(band)에 설정되어 있었기에, 대역 계산식을 수정하여 배포했습니다.
여전히 비어 있었습니다. 두 번째 PR. 진행 이벤트(progress events)는 도구 호출(tool call)이 발생할 때만 트리거되는데, 한 검색 작업이 3분간의 실행 중 94초를 차지하면서 이벤트 사이의 표시줄이 멈춰버리는 현상이 있었습니다. 저는 서버 업데이트 사이에 애니메이션을 구현하는 클라이언트 측 트리클(trickle) 방식을 추가했습니다. 배포했습니다.
여전히 비어 있었습니다. 버그 보고 작성자는 초록색이 전혀 보이지 않는다고 계속 주장했습니다. 10%만 넘어도 눈으로 식별할 수 있어야 한다는 것이었습니다. 저는 요소에서 style.width 값을 읽어와 합리적인 백분율이 나오는 것을 확인하며 두 번의 수정 사항을 "검증"했습니다. 숫자는 매번 정확했습니다.
세 번째 PR에서 원인을 찾아냈습니다. 채우기(fill) 요소가 <span>이었습니다. <span>은 기본적으로 display: inline이며, 인라인 박스(inline box)에는 너비(width)가 영향을 미치지 않기 때문에, 채우기 영역이 0x0 픽셀로 붕괴되어 아무것도 그려지지 않았던 것입니다. 제 데모 컴포넌트는 <div>를 사용했기에 잘 작동했습니다. 저는 그 로직을 프로덕션 배지(production badge)에 복사하면서, 아무 생각 없이 요소 타입만 바꾼 것이었습니다.
style.width는 5%, 21%, 34%를 보고했습니다. 하지만 getBoundingClientRect()는 내내 너비가 0이라고 보고했습니다. 저는 브라우저가 그린 픽셀이 아니라, 제가 설정한 값을 읽고 있었던 것입니다. 붕괴된 인라인 요소의 경우, 이 두 값은 서로 아무런 상관이 없었습니다.
해결책은 단 한 줄의 CSS였습니다. 하지만 얻은 교훈은 그 해결책보다 훨씬 컸습니다. 사용자가 보는 결과물(artifact)을 테스트하십시오, 읽기 편리한 대리자(proxy)를 테스트하지 말고 말입니다. 초록색 바는 화면 위의 초록색 픽셀입니다. 스타일 속성(style attribute) 안에 있는 문자열이 아닙니다. 저는 세 번의 과정 동안 대리자를 신뢰했고, 확신에 찬 어조로 말하는 숫자가 저를 속이도록 내버려 두었습니다.
무엇이 발목을 잡았는가
세 가지 수정 사항 모두에 적용되는 패턴은 동일합니다. 확률적인 핵심(probabilistic core)을, 모든 되돌릴 수 없는 동작을 소유하고, 모델이 내뱉는 모든 숫자를 통제하며, 모델의 설명이 아닌 실제 세계를 기준으로 검증하는 결정론적인 배관(deterministic plumbing)으로 감싸는 것입니다.
Mediary Scout의 에이전트는 의도적으로 작게 설계되었습니다. 에이전트는 검색 결과를 읽고 선택합니다. 실행이 완료되었는지 결정할 수 없고, 커버리지를 속이기 위해 파일 수를 셀 수 없으며, 검색 예산을 초과할 수 없고, 샌드박스(sandbox) 외부의 디렉토리에 접근할 수 없습니다. 이러한 제약 사항들은 모델이 결코 볼 수 없고 논쟁할 수도 없는 코드 안에 존재합니다. 제가 시스템에 대해 신뢰하는 모든 것은 모델이 그렇게 말했기 때문이 아니라, 결정론적인 함수가 이를 확인했기 때문에 신뢰하는 것입니다.
만약 여러분이 실제로 되돌릴 수 없는 일을 수행하는 에이전트를 구축하고 있다면, 바로 이 부분이 여러분의 주의를 가장 많이 기울여야 할 대목입니다. 모델은 여러분을 놀라게 할 것입니다. 여러분의 역할은 그 놀라움의 비용을 저렴하게 만드는 것입니다.
이 프로젝트는 github.com/fancydirty/mediary-scout에서 오픈 소스로 제공되며 셀프 호스팅이 가능합니다. 라이브 데모는 demo.mediaryscout.app에서, 데스크톱 빌드는 mediaryscout.app에서 확인할 수 있습니다. 드라이브 백엔드(backends)는 도메인 세부 사항일 뿐입니다. 배관(plumbing)이야말로 제가 어디에서든 재사용하고 싶은 부분입니다.
한 가지 실질적인 참고 사항을 말씀드리자면, 현재 이 모델이 통신하는 드라이브는 중국의 서비스들이며 대부분의 독자들은 이를 사용하지 않을 것입니다. 만약 이 패턴이 유용하다고 느껴지고 여러분만의 드라이브(Google Drive, Dropbox, 또는 특정 지역 서비스)를 지원하고 싶다면, 드라이브 레이어(drive layer)는 브랜드 레지스트리(brand registry) 뒤에 있는 독립적인 플러그인, 즉 클라이언트(client)와 전송 실행기(transfer executor)의 조합으로 구성됩니다. 저는 PR(Pull Request)을 환영하며, 범위를 정하는 데 기꺼이 도움을 드리겠습니다. 이것이 모델의 폭발 반경(blast radius)을 작게 유지함으로써 얻는 보상입니다. 경계가 충분히 깔끔하게 구분되어 있어, 드라이브를 추가하는 것은 정밀 수술이 아닌 제한된 범위 내의 작업(bounded work)이 됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기