Published on

AutoGPT 메모리 폭주·환각 줄이는 Qdrant 실전

Authors

AutoGPT 계열 에이전트를 운영하다 보면 두 가지 문제가 빠르게 터집니다. 첫째는 메모리(장기 기억)가 끝없이 쌓이면서 검색 비용과 토큰 비용이 같이 폭주하는 문제, 둘째는 관련 없는 기억을 끌고 와서 그럴듯하게 엮어 말하는 환각입니다. 이 글은 이 두 문제를 벡터DB로 줄이는 실전 접근을 다룹니다. 특히 Qdrant를 Rust 서비스로 감싸, “저장 전략”과 “검색 전략”을 분리해 운영 가능한 형태로 만드는 데 초점을 둡니다.

핵심 요지는 간단합니다.

  • 메모리는 “많이 저장”이 아니라 “잘 버리고, 잘 요약하고, 잘 찾는” 문제입니다.
  • 환각은 모델 문제가 아니라 “검색 결과의 품질”과 “컨텍스트 구성 규칙”이 만드는 경우가 많습니다.
  • Qdrant는 필터링(메타데이터), 페이로드, 스코어, HNSW 파라미터, TTL/삭제 전략을 조합하기 좋아 AutoGPT류에 잘 맞습니다.

운영 중 OOM이나 캐시/메모리 문제가 함께 보이면, 로컬 LLM 메모리 최적화 관점도 같이 점검하는 게 좋습니다. 예를 들어 4bit 양자화나 KV 캐시 튜닝은 에이전트가 장시간 돌 때 효과가 큽니다. 관련해서는 Transformers 로컬 LLM OOM 해결 - 4bit+KV캐시도 같이 참고하면 연결이 됩니다.

왜 AutoGPT는 메모리가 폭주하고 환각이 늘어날까

1) “기억”이 로그처럼 누적된다

AutoGPT류는 보통 다음을 전부 “기억”으로 저장하려고 합니다.

  • 사용자 지시/대화
  • 중간 추론(Thought/Plan)
  • 도구 호출 결과(웹 검색, DB 쿼리, 파일 읽기)
  • 실패/재시도 로그

이게 그대로 벡터화되어 들어가면, 유사도 검색이 “중요한 사실”보다 “자주 등장하는 문장 패턴”을 더 잘 잡습니다. 결과적으로 검색 품질이 떨어지고, 더 많은 컨텍스트를 넣으려다 토큰이 늘고, 그게 다시 메모리로 저장되는 악순환이 생깁니다.

2) 유사도 검색만으로는 ‘관련성’이 충분하지 않다

벡터 유사도는 의미적 근접을 주지만, 에이전트에게 필요한 건 종종 다음입니다.

  • 최신성(최근에 바뀐 정책/상태)
  • 권한/테넌트 격리(다른 사용자 기억이 섞이면 치명적)
  • 작업 단위(현재 태스크/프로젝트 범위)
  • 사실성(검증된 소스인지)

즉 “코사인 유사도 상위 K개”만 넣으면 환각을 유도하기 쉽습니다. Qdrant의 강점은 여기에 필터(메타데이터 조건), 스코어 임계값, 최신성 가중, 컬렉션 분리 같은 운영적 장치를 얹기 쉽다는 점입니다.

아키텍처: AutoGPT와 Qdrant 사이에 Rust 메모리 게이트웨이 두기

구성은 다음처럼 잡는 게 실전에서 깔끔합니다.

  • AutoGPT(또는 에이전트 런타임)
  • Rust memory-service
    • 저장 정책(요약, dedup, TTL, 중요도)
    • 검색 정책(필터, 임계값, rerank)
  • Qdrant

이렇게 분리하면 에이전트 코드는 “저장/검색 API”만 호출하고, 메모리 정책은 서버에서 바꿀 수 있어 실험 속도가 빨라집니다.

데이터 모델(페이로드) 설계

Qdrant 포인트에는 벡터와 함께 JSON 페이로드를 붙일 수 있습니다. 최소 권장 필드는 아래입니다.

  • tenant_id: 멀티테넌시 격리
  • agent_id: 에이전트 인스턴스 구분
  • task_id: 현재 작업 단위
  • kind: fact, observation, tool_output, summary, error
  • source: user, web, db, file
  • created_at: epoch seconds
  • ttl_until: 만료 시각(또는 별도 정책)
  • importance: 0~1 가중치
  • hash: dedup용 콘텐츠 해시

이게 있어야 “유사도 + 필터 + 최신성”을 조합할 수 있습니다.

Qdrant 컬렉션 생성과 인덱스 튜닝

Qdrant는 HNSW 기반 근사 최근접 탐색이 기본입니다. 운영에서 중요한 건 다음 두 가지 균형입니다.

  • 검색 품질(재현율) vs 메모리/CPU
  • 삽입 성능 vs 검색 성능

아래는 HTTP API로 컬렉션을 만드는 예시입니다(운영에 맞게 수치 조정).

curl -X PUT "http://localhost:6333/collections/memory" \
  -H "Content-Type: application/json" \
  -d '{
    "vectors": {"size": 1536, "distance": "Cosine"},
    "hnsw_config": {"m": 16, "ef_construct": 128},
    "optimizers_config": {"default_segment_number": 2},
    "on_disk_payload": true
  }'
  • on_disk_payload: 페이로드가 커질 때 RAM 압박을 줄이는 데 유리합니다.
  • m, ef_construct: 품질을 올리면 메모리와 인덱싱 비용이 증가합니다.

벡터 차원 1536은 OpenAI text-embedding-3-small 같은 임베딩을 가정한 값입니다. 다른 모델이면 차원을 맞추면 됩니다.

Rust로 Qdrant 연동: 저장(Upsert)과 검색(Search)

Rust에서는 qdrant-client를 쓰는 방식이 일반적입니다. 아래 코드는 “저장 게이트웨이”의 핵심 흐름을 보여줍니다.

주의: MDX 빌드 에러를 막기 위해 제네릭 표기 Vec<T> 같은 건 인라인 코드로 감쌌습니다.

use qdrant_client::prelude::*;
use serde_json::json;

pub async fn upsert_memory(
    client: &QdrantClient,
    collection: &str,
    id: u64,
    embedding: Vec<f32>,
    payload: serde_json::Value,
) -> anyhow::Result<()> {
    let points = vec![PointStruct::new(id, embedding, payload)];

    client
        .upsert_points(collection, None, points, None)
        .await?;

    Ok(())
}

pub async fn search_memory(
    client: &QdrantClient,
    collection: &str,
    query_embedding: Vec<f32>,
    tenant_id: &str,
    agent_id: &str,
    task_id: &str,
    limit: u64,
) -> anyhow::Result<Vec<ScoredPoint>> {
    let filter = Filter::must([
        Condition::matches("tenant_id", tenant_id),
        Condition::matches("agent_id", agent_id),
        Condition::matches("task_id", task_id),
    ]);

    let search = SearchPoints {
        collection_name: collection.to_string(),
        vector: query_embedding,
        filter: Some(filter),
        limit,
        with_payload: Some(true.into()),
        score_threshold: Some(0.25),
        ..Default::default()
    };

    let res = client.search_points(&search).await?;
    Ok(res.result)
}

여기서 중요한 포인트는 score_threshold입니다. 이 값을 두지 않으면 “아무거나라도” 상위 K개가 들어와 환각을 유도합니다. 운영에서는 태스크 특성에 따라 0.2~0.5 사이를 실험해 임계값을 잡는 경우가 많습니다.

메모리 폭주를 막는 저장 전략 4가지

1) Dedup(중복 제거): 콘텐츠 해시 + 근접 중복

에이전트는 같은 내용을 형태만 바꿔 여러 번 저장합니다. 최소한 다음 두 단계를 권장합니다.

  • 1차: 원문 정규화 후 해시(sha256)로 완전 중복 제거
  • 2차: 같은 task_id에서 최근 N개 중 유사도 검색으로 근접 중복 제거

근접 중복은 “새로 저장하려는 임베딩”으로 limit=5 정도 검색한 뒤, 스코어가 높으면 저장을 스킵하는 식으로 구현합니다.

2) Kind 분리: tool_output은 원문 대신 요약을 저장

웹 페이지/로그/JSON 응답을 통째로 벡터화하면 페이로드도 커지고 검색 잡음도 늘어납니다.

  • 원문은 오브젝트 스토리지나 파일로 저장
  • 벡터DB에는 summarysource_ref만 저장

이 패턴은 “장기 기억”을 사실 중심으로 유지하는 데 효과적입니다.

3) TTL과 세그먼트 청소: 오래된 관측은 자동 만료

모든 기억이 영구 보관일 필요는 없습니다.

  • observation: TTL 7일
  • tool_output: TTL 3일
  • fact/summary: TTL 30일 또는 수동 정리

Qdrant는 포인트 삭제를 지원하므로, Rust에서 주기적으로 ttl_until 지난 데이터를 삭제하는 잡을 돌리면 됩니다.

curl -X POST "http://localhost:6333/collections/memory/points/delete" \
  -H "Content-Type: application/json" \
  -d '{
    "filter": {
      "must": [
        {"range": {"ttl_until": {"lt": 1700000000}}}
      ]
    }
  }'

4) 중요도 기반 샘플링: “많이 저장” 대신 “좋은 것만 저장”

저장 전에 간단한 룰 기반 점수화를 두면 효과가 큽니다.

  • 사용자 명시 요구사항 포함: +0.3
  • 숫자/식별자/URL 포함: +0.2
  • 오류 로그/재시도 로그: -0.4
  • 길이 과다: -0.2

importance가 낮으면 저장하지 않거나, 요약만 저장합니다.

환각을 줄이는 검색 전략 5가지

1) 필터는 필수: 테넌트/에이전트/태스크 스코프 고정

유사도 검색만 쓰면 다른 태스크의 기억이 섞입니다. 특히 멀티테넌시는 사고로 직결됩니다.

  • tenant_id는 항상 must 조건
  • task_id는 기본 must, 필요 시 “전역 기억” 컬렉션을 별도로 둠

2) 최신성 가중: 시간 감쇠를 스코어에 반영

Qdrant 자체 스코어는 유사도 중심입니다. 따라서 Rust에서 후처리로 시간 감쇠를 적용해 rerank하는 게 실전적입니다.

  • 최종 점수 = alpha * vector_score + (1 - alpha) * recency_score
  • recency_scoreexp(-age_days / tau) 같은 형태

3) 스코어 임계값 + 최소 근거 개수

컨텍스트에 들어갈 “근거”를 최소 2개 이상 요구하는 규칙도 환각을 줄입니다.

  • 임계값 이상 결과가 1개뿐이면 “기억 없음”으로 처리
  • 또는 웹/DB 같은 외부 검증 도구 호출로 전환

4) Rerank(재정렬): 작은 크로스인코더 또는 LLM 판정

가능하면 2단계 검색을 권장합니다.

1단계: Qdrant로 후보 top_k=20 2단계: reranker로 top_k=5로 압축

reranker는 비용이 들지만, “관련 없는 기억이 섞여 들어가는 문제”를 크게 줄입니다.

5) 컨텍스트 템플릿을 고정: “기억은 참고자료”로만 쓰게 만들기

에이전트 프롬프트에 다음 규칙을 명시합니다.

  • 기억은 사실이 아닐 수 있다
  • 기억은 반드시 출처(source, created_at)와 함께 인용한다
  • 기억만으로 결론을 내지 말고, 필요하면 도구로 검증한다

이건 모델의 환각을 “운영 규칙”으로 억제하는 장치입니다.

운영 팁: 폭주/장애를 미리 막는 관측 포인트

1) Qdrant 메트릭과 알람

다음 지표를 최소로 봅니다.

  • 컬렉션 포인트 수 증가율
  • 평균 검색 latency, p95 latency
  • segment 수 증가(최적화 지연 신호)
  • 메모리 사용량(인덱스가 RAM을 먹는지)

컨테이너 환경에서 장애가 나면 원인이 다양합니다. 특히 에이전트 워커가 CrashLoopBackOff로 빠질 때는 “메모리 폭주”와 “외부 의존성 실패”가 섞여 나타납니다. 진단 루틴은 K8s CrashLoopBackOff 10분 원인별 진단법을 참고하면 빠르게 좁힐 수 있습니다.

2) Rust 서비스의 에러 처리: 예외 대신 명시적 실패 경로

메모리 서비스는 외부 시스템(Qdrant, 임베딩 API)에 의존하므로 실패가 빈번합니다. Rust에서 Result를 일관되게 쓰고, 재시도/서킷브레이커를 둬야 에이전트 전체가 무너지지 않습니다.

C++을 쓰는 팀이라면 “예외 없는 에러 처리” 철학이 비슷하게 연결됩니다. 설계 감각을 넓히는 참고로 C++23 std - -expected로 예외 없이 에러처리+회수도 같이 보면 좋습니다.

실전 시나리오: AutoGPT 메모리 파이프라인 예시

다음은 “관측을 저장하고, 질문 시 검색해서 컨텍스트를 구성”하는 현실적인 흐름입니다.

  1. 도구 호출 결과 수집
  2. 원문이 길면 요약 생성
  3. dedup 검사
  4. 임베딩 생성
  5. Qdrant upsert
  6. 사용자 질문 시: 임베딩 생성
  7. Qdrant 검색(필터 + 임계값)
  8. 최신성 가중 rerank
  9. 상위 3~5개만 컨텍스트에 포함
  10. 답변 생성 후, 최종 요약만 장기 기억으로 저장

이 파이프라인에서 “중간 추론 로그”는 가능한 저장하지 않거나, 주기적으로 요약본만 남기는 게 메모리 폭주를 막는 데 결정적입니다.

체크리스트: 바로 적용할 수 있는 튜닝 우선순위

  • score_threshold를 반드시 둔다
  • tenant_id/task_id 필터를 기본으로 강제한다
  • tool_output 원문 저장을 금지하고 요약+참조로 바꾼다
  • dedup(해시 + 근접 중복)를 넣는다
  • TTL 삭제 잡을 돌린다
  • 후보 top_k는 크게, 컨텍스트 k는 작게(예: 20에서 5로)
  • rerank(시간 감쇠라도)로 재정렬한다

마무리

AutoGPT의 메모리 폭주와 환각은 “벡터DB를 붙이면 해결”되는 문제가 아니라, 저장과 검색의 정책을 분리하고 강제하는 순간부터 줄어듭니다. Qdrant는 필터링과 페이로드 중심 설계가 좋아서, Rust로 메모리 게이트웨이를 만들면 운영 중에도 정책을 빠르게 바꿀 수 있습니다.

다음 단계로는 (1) 전역 기억과 태스크 기억 컬렉션 분리, (2) reranker 도입, (3) 사실 검증 도구(웹/DB) 자동 전환까지 붙이면, 환각은 체감상 크게 줄고 비용도 안정화됩니다.