
RabbitHole을 구축하며 겪은 즐거운 뇌 과부하
요약
LangGraph를 활용하여 여러 에이전트 페르소나가 논쟁을 통해 결론을 도출하는 멀티 에이전트 시스템 'RabbitHole' 구축 경험을 공유합니다. 단일 RAG의 한계를 극복하기 위해 법정 그래프와 RAG 서브 그래프로 구조를 분리하여 디버깅 효율과 복잡한 문제 해결 능력을 높였습니다.
핵심 포인트
- LangGraph 기반의 멀티 에이전트 논쟁 시스템 구축
- 단일 RAG의 평탄화 문제를 해결하기 위한 계층적 그래프 구조 설계
- 하이브리드 검색, Jina reranker, CRAG 루프를 통한 검색 품질 강화
- 그래프 분리를 통한 검색(Retrieval)과 논증(Arguing) 단계의 격리 및 디버깅 최적화
자, 몇 주 동안 이 프로젝트를 붙들고 있었는데, 드디어 법정(courtroom) 기능이 엔드 투 엔드(end to end)로 제대로 작동하기 시작했습니다. 그래서 기억이 생생할 때 이 프로젝트에 대한 모든 것을 쏟아내 보려고 합니다.
RabbitHole은 LangGraph를 기반으로 구축된 멀티 에이전트(multi agent) 시스템입니다. 하나의 LLM에게 "이 질문의 답이 뭐야?"라고 물어보고 확신에 찬 한 단락의 답변을 받는 대신, 저는 여러 에이전트 페르소나(agent personas)가 실제로 그 문제에 대해 논쟁하도록 만들었습니다. 예를 들어, 국가 변호인(state advocate) vs 개인정보 보호 활동가(privacy activist) vs 컴플라이언스 담당자(compliance officer)가 동일한 검색된 문서(retrieved docs)를 바탕으로 완전히 다른 입장에서 논쟁하고, 서로를 교차 심문(cross examining)하며, 마지막으로 사법 노드(judiciary node)가 신뢰도 점수(confidence score)와 함께 최종 판결을 내려야 하는 방식입니다.
왜 이렇게 했을까요? 왜냐하면 일반적인 RAG는 모든 것을 평탄화(flattens)해버리기 때문입니다. 명확한 답이 없는 질문(법률 문제, 정책적 트레이드오프, 혹은 진정으로 논쟁의 여지가 있는 모든 것)을 던져도, RAG는 마치 질문 자체가 애초에 복잡하지 않았던 것처럼 깔끔한 한 단락의 답변을 내놓습니다. 그 점이 항상 저를 짜증 나게 했습니다. 때로는 그 복잡함 자체가 핵심인데 말이죠.
참고로 아직 배포 전입니다. 이건 준비가 안 된 게 아니라 순전히 비용 문제입니다. 그 부분은 나중에 다루겠습니다.
사실 두 개의 그래프로 구성되어 있습니다
사람들은 이것이 하나의 커다란 그래프인지 묻곤 하는데, 아닙니다. 외부의 법정(Courtroom) 그래프가 있고(쿼리를 정제하고, RAG를 호출하며, 중재자가 토론자를 선정하고, 토론을 병렬로 실행한 뒤, 결론을 내리기 전에 사용자의 의견을 기다립니다), 그 내부에 별도의 RAG 서브 그래프(sub-graph)가 중첩되어 각자의 역할을 수행합니다.
RAG 부분만 해도 시작할 때 예상했던 것보다 훨씬 많은 작업이 진행됩니다. 하이브리드 검색(hybrid search, 법률 인용구의 키워드 매칭이 매우 중요하기 때문에 Pinecone dense + BM25 sparse를 사용하며, 시맨틱 검색(semantic search)만으로는 이를 놓칠 수 있습니다), 노이즈를 제거하기 위한 Jina reranker, 그리고 CRAG 루프(CRAG loop)가 있습니다. Grader가 검색된 문서가 실제로 괜찮은지 확인하며, 그렇지 않을 경우 잘못된 컨텍스트로 무작정 진행(yolo-ing)하는 대신 웹 검색으로 전환합니다. 게다가 그 위에는 self-RAG 환각 체크(hallucination check)가 있어, 최종 요약본이 서브 그래프를 벗어나기 전에 원본 소스(raw source)와 대조하여 검토를 거칩니다.
단일한 플랫 파이프라인 (flat pipeline) 대신 두 개의 그래프로 분리한 것은 솔직히 제가 내린 결정 중 가장 잘한 일 중 하나였습니다. 순전히 잘못된 판결이 나왔을 때, 그것이 잘못된 검색 (retrieval) 때문인지 아니면 잘못된 논증 (arguing) 때문인지를 격리하여 파악할 수 있었기 때문입니다. 디버깅 시간을 엄청나게 아껴주었습니다. lol
진짜 나를 짜증 나게 했던 버그
초기 버전에서는 2개의 관점을 요청하면 6~8개 정도가 돌아왔습니다. 시스템 프롬프트 (system prompt)에는 심지어 대문자로 "정확히 2개의 관점만 사용하세요"라고 적어두었는데도 모델이 그냥... 듣지 않았습니다. 그리고 실제 부하가 걸릴 때마다 이는 Groq의 속도 제한 (rate limit)을 거의 즉시 소진한다는 것을 의미했고, 실시간으로 그 광경을 지켜보는 것은 전혀 즐겁지 않았습니다.
해결책이 더 나은 프롬프트가 아니라, 이 문제에 대해서는 프롬프트를 전혀 신뢰하지 않는 것이라는 점을 깨닫는 데 너무 오랜 시간이 걸렸습니다. 제약 조건을 상태 스키마 (state schema) 자체로 옮겼습니다. 즉, 중재자 노드 (moderator node)가 상태 (state)에서 직접 관점 개수에 대한 타입화된 필드 (typed field)를 읽어 들여 오직 그 개수만큼의 노드만 스케줄링하도록 만든 것입니다. LLM은 말 그대로 숫자를 세라는 요청을 아예 받지 않으며, 그래프 토폴로지 (graph topology) 자체가 그것을 허용하지 않습니다.
어쨌든 이것이 제가 지금도 스스로에게 계속 되뇌는 교훈입니다. 만약 어떤 것이 구조적인 문제라면, 모델에게 제대로 행동해달라고 애원하지 말고 구조적으로 인코딩(encode)하세요.
속도 제한이 사실상 아키텍처의 절반을 설계함
Groq 무료 티어는 성능이 좋은 모델 기준으로 분당 30회 요청, 분당 6,000 토큰입니다. 법정 토론 (courtroom debate)에서 관점들을 병렬로 실행하면 과장 없이 몇 초 만에 이를 다 써버립니다. 그래서 저는 429 오류와 연결 오류를 포착하여 — 그래프 상태 (graph state)가 문제가 발생했다는 것을 전혀 눈치채지 못한 채 — Cerebras에서 Groq로, 다시 Gemini로 페일오버 (failover)하는 FallbackChatModel 래퍼 (wrapper)를 구축했습니다.
또한 시작 시점에 .env에 실제로 어떤 키가 있는지 확인하고, 무거운 작업과 가벼운 작업에 대한 라우팅 순서를 스스로 결정합니다. 그리고 페일오버뿐만 아니라 라우팅 자체도 중요합니다. 구조화된 합성 (structured synthesis, 실제 논증 및 판결)은 더 무거운 모델인 Llama 3.3 70B 또는 Gemini 1.5 Pro로 보내고, "이 문서가 관련이 있는가 y/n"와 같은 지루한 불리언 (boolean) 작업은 가벼운 모델인 Llama 3.1 8B 또는 Gemini Flash로 보냅니다. 이를 통해 대부분의 노드 호출이 비싼 할당량 (quota)을 전혀 사용하지 않도록 유지했습니다.
정말로 감탄하게 만든 지연 시간 (latency) 문제
19.8초에서 9.8초로 단축되었습니다. 약 51%가 절감되었으며, 솔직히 단 두 가지의 변경만으로 이루어낸 결과입니다.
첫째는 Perspective 노드들을 하나씩 실행하는 대신 LangGraph의 비동기 스케줄러 (async scheduler)를 사용하여 병렬로 실행한 것이고 (솔직히 첫날부터 이렇게 했어야 했습니다), 둘째는 합성 (synthesis) 전에 Jina를 사용하여 재순위화 (reranking)를 수행함으로써 무거운 모델로 들어가는 컨텍스트 (context)의 크기를 줄인 것입니다. 이는 추론 (inference) 속도를 높일 뿐만 아니라 토큰 비용 (token cost)도 절감해주므로, 일석이조의 효과를 가져왔습니다.
여기서 특별하거나 기이한 기술이 사용된 것은 아닙니다. 승부처는 "더 나은 모델로 교체"하는 것이 아니라 아키텍처 (architectural)적인 개선이었습니다.
배포하지 않은 이유
이 부분에 대해서는 저를 비난하지 말아주세요 (lol). Pinecone 인덱스 (index)와 재순위화 (reranker) 호출을 포함하여 멀티 프로바이더 (multi provider), 멀티 에이전트 (multi agent) 그래프를 24시간 내내 호스팅하는 것은 비용이 만만치 않습니다. 배포한 뒤 한 달 만에 서비스가 죽어가는 것을 지켜보기보다는, 실제로 유지할 여력이 생길 때까지 기다리는 편을 택하겠습니다. 현재 모든 것은 로컬에서 Docker Compose를 통해 실행됩니다. docker-compose up --build 명령 한 번으로 Nginx 뒤에서 작동하는 FastAPI 백엔드와 React 프론트엔드를 한 번에 실행할 수 있습니다. 배포는 "할 것인가"의 문제가 아니라 "언제 할 것인가"의 문제입니다.
다음 단계
이제 파이프라인 (pipeline)이 실제로 작동하므로, 이를 제대로 계측 (instrument)하고 싶습니다. 순서는 다음과 같습니다: 우선 LangSmith에서 노드별 비용 추적 (cost tracing)을 구현하는 것입니다 (현재는 실행 비용이 많이 들었다는 것은 알 수 있지만, '어느' 노드가 그랬는지는 알 수 없어 미칠 지경입니다). 그다음은 라이브 파이프라인에 RAGAS 평가 (eval)를 적용하여 단순히 느낌(vibes-checking)으로 판단하는 것이 아니라 품질을 측정하는 것입니다. 그 후 프롬프트 캐싱 (prompt caching), 그리고 기존 구조 위에 모델 라우팅 (model routing) 및 캐스케이드 (cascades)를 구축할 계획입니다.
구경해보고 싶으시다면 리포지토리 (repo)는 여기 있습니다: github.com/Somay-kousis/RabbitHole
사람들이 원한다면 후속 글을 통해 CRAG 폴백 (fallback), 상태 스키마 (state schema) 수정, 페일오버 래퍼 (failover wrapper) 등 어떤 것이든 더 깊게 다룰 의향이 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기