Published on

AutoGPT 메모리 팽창·환각 줄이는 RAG+벡터DB

Authors

AutoGPT류 에이전트(자율 실행 루프를 돌며 계획-실행-반성하는 구조)는 조금만 운영해도 두 가지 문제가 빠르게 커집니다. 첫째는 메모리 팽창입니다. 매 스텝의 로그와 중간 산출물을 모두 프롬프트로 끌고 가면 토큰이 선형이 아니라 누적적으로 증가해 비용과 지연이 폭증합니다. 둘째는 환각입니다. 에이전트가 과거의 부정확한 요약, 오래된 가정, 혹은 잘못된 외부 지식을 “기억”으로 신뢰하면서 자신감 있게 틀린 결론을 내립니다.

이 글은 이 두 문제를 “프롬프트 엔지니어링” 수준이 아니라, RAG(Retrieval-Augmented Generation) + 벡터DB 기반의 메모리 아키텍처로 해결하는 방법을 다룹니다. 핵심은 간단합니다.

  • 장기 기억은 프롬프트가 아니라 검색 인덱스에 둔다
  • 필요할 때만 가져오고, 가져온 근거에만 답하게 만든다
  • 메모리는 계층화하고, 증거 기반으로 갱신한다

AutoGPT에서 메모리가 왜 폭발하는가

AutoGPT의 전형적인 루프는 다음과 같습니다.

  1. 목표 입력
  2. 계획 생성
  3. 도구 호출(웹 검색, 코드 실행, DB 조회 등)
  4. 결과를 “메모리”에 저장
  5. 다음 스텝 프롬프트에 과거 기록을 포함

문제는 4~5번에서 생깁니다. “메모리”라는 이름으로 사실상 전체 대화 로그와 작업 로그를 그대로 컨텍스트에 재주입하면, 컨텍스트는 매 스텝 누적됩니다. 요약을 하더라도 요약 자체가 계속 누적되고, 요약의 품질이 떨어지면 환각의 씨앗이 됩니다.

실무적으로는 다음 현상이 자주 보입니다.

  • 에이전트가 이미 해결한 하위 작업을 다시 수행(중복 실행)
  • 오래된 계획을 계속 고집(목표 드리프트)
  • “이전에 확인했다”는 말만 남고 근거 URL이나 데이터가 없음(검증 불가)

해결의 출발점은 메모리를 프롬프트에서 분리하는 것입니다.

RAG 기반 메모리: 프롬프트에 넣지 말고 검색하라

RAG는 보통 “문서 기반 Q&A”로 알려져 있지만, 에이전트 메모리에도 동일하게 적용할 수 있습니다.

  • 에이전트가 생성하거나 관측한 정보를 청크(chunk) 로 쪼개 벡터화
  • 벡터DB에 저장
  • 다음 스텝에서 필요한 것만 top-k로 검색해 컨텍스트에 삽입

여기서 중요한 점은, “뭐든 저장”이 아니라 저장 단위와 메타데이터 설계입니다.

메모리를 4계층으로 나누기

운영 안정성을 위해 메모리를 다음처럼 계층화하면 좋습니다.

  1. Working memory: 현재 스텝에 필요한 최소 컨텍스트(최근 1~3턴)
  2. Episodic memory: 작업 과정에서 나온 사건/결정/결과(청크로 저장)
  3. Semantic memory: 재사용 가능한 지식(정의, 규칙, 시스템 제약)
  4. Artifact memory: 파일, 코드, 표, JSON 결과물(원문을 저장하고 참조)

이 중 2~4는 프롬프트에 상시 포함하지 않고, RAG로만 가져오게 만드는 것이 핵심입니다.

벡터DB에 어떤 필드를 저장해야 하나

최소한 아래 메타데이터는 넣어야 검색 품질과 후처리가 좋아집니다.

  • memory_type: episodic, semantic, artifact
  • source: tool 이름 또는 URL, 파일 경로 등(부등호는 인라인 코드로)
  • timestamp: 생성 시각
  • task_id: 상위 목표/세션 단위
  • confidence: 자동 평가 점수(휴리스틱 가능)
  • tags: 도메인 키워드

검색 결과를 컨텍스트에 넣을 때는 “텍스트만” 넣지 말고, 출처와 근거를 함께 넣어야 환각을 줄일 수 있습니다.

환각이 줄어드는 이유: 근거 강제와 경쟁 가설

RAG는 단순히 “정보를 더 준다”가 아니라, 모델의 추론 경로를 근거 중심으로 바꾸는 장치입니다.

1) 근거 없는 답변을 금지하는 프롬프트 구조

에이전트 프롬프트를 다음처럼 바꾸면 효과가 큽니다.

  • 검색 결과가 없으면 “모른다” 혹은 “추가 조사 필요”로 종료
  • 답변은 반드시 검색 스니펫의 source를 인용
  • 계획 단계에서 필요한 근거 목록을 먼저 만들고, 그 근거가 충족되었는지 체크

이렇게 하면 모델이 내부 지식으로 때우는 비율이 줄어듭니다.

2) top-k를 늘리는 대신 “다양성”을 확보

환각은 종종 편향된 단일 근거에서 시작합니다. top-k를 무작정 늘리기보다 다음을 권장합니다.

  • 서로 다른 source에서 최소 2개 이상 가져오기
  • memory_type을 섞기(episodic 2개, semantic 1개 등)
  • 유사도 점수 상위만 쓰지 말고 MMR(Maximal Marginal Relevance)로 중복 제거

벡터 검색 품질 튜닝은 HNSW 파라미터나 리랭킹 전략에 따라 크게 달라집니다. 관련해서는 Pinecone·Milvus 검색품질 튜닝 - HNSW 파라미터 글이 도움이 됩니다.

구현 예시: Node.js로 RAG 메모리 레이어 만들기

아래는 “메모리 저장”과 “메모리 검색”을 분리한 최소 구조 예시입니다. 벡터DB는 제품마다 API가 다르므로, 인터페이스 중심으로 작성합니다.

1) 메모리 청크 스키마

// types.ts
export type MemoryType = 'episodic' | 'semantic' | 'artifact'

export type MemoryChunk = {
  id: string
  taskId: string
  type: MemoryType
  text: string
  source?: string
  tags?: string[]
  timestamp: number
  confidence?: number
}

2) 임베딩 + 업서트

// memoryStore.ts
import crypto from 'node:crypto'
import type { MemoryChunk } from './types'

export type VectorDb = {
  upsert: (input: {
    id: string
    vector: number[]
    metadata: Record<string, unknown>
  }) => Promise<void>
  query: (input: {
    vector: number[]
    topK: number
    filter?: Record<string, unknown>
  }) => Promise<Array<{ id: string; score: number; metadata: any }>>
}

export type Embedder = {
  embed: (text: string) => Promise<number[]>
}

export function makeId(text: string) {
  return crypto.createHash('sha256').update(text).digest('hex').slice(0, 24)
}

export async function saveMemory(
  db: VectorDb,
  embedder: Embedder,
  chunk: Omit<MemoryChunk, 'id'>
) {
  const id = makeId(`${chunk.taskId}:${chunk.type}:${chunk.timestamp}:${chunk.text}`)
  const vector = await embedder.embed(chunk.text)

  await db.upsert({
    id,
    vector,
    metadata: {
      taskId: chunk.taskId,
      type: chunk.type,
      text: chunk.text,
      source: chunk.source,
      tags: chunk.tags ?? [],
      timestamp: chunk.timestamp,
      confidence: chunk.confidence ?? null
    }
  })

  return id
}

3) 검색 후 컨텍스트 구성

// retrieve.ts
import type { VectorDb, Embedder } from './memoryStore'

export async function retrieveMemoryContext(opts: {
  db: VectorDb
  embedder: Embedder
  taskId: string
  query: string
  topK?: number
  types?: Array<'episodic' | 'semantic' | 'artifact'>
}) {
  const { db, embedder, taskId, query } = opts
  const topK = opts.topK ?? 6
  const types = opts.types ?? ['episodic', 'semantic']

  const q = await embedder.embed(query)

  const results = await db.query({
    vector: q,
    topK,
    filter: {
      taskId,
      type: { $in: types }
    }
  })

  // LLM에 넣기 좋은 형태로 정리: 출처 포함
  const lines = results.map((r, i) => {
    const m = r.metadata
    const source = m.source ? String(m.source) : 'unknown'
    const text = String(m.text)
    return `[#${i + 1} score=${r.score.toFixed(3)} source=${source}]\n${text}`
  })

  return lines.join('\n\n')
}

이제 에이전트 루프에서는 “전체 히스토리” 대신, retrieveMemoryContext로 만들어진 “근거 묶음”만 넣습니다.

메모리 팽창을 막는 운영 전략

RAG를 붙였는데도 비용이 늘어나는 경우가 있습니다. 대개 “저장량”이 아니라 “검색 결과를 너무 많이 프롬프트에 넣는” 문제입니다.

1) 토큰 예산을 먼저 정하고 역으로 설계

  • working memory: 예를 들어 800 토큰
  • retrieved context: 1,200 토큰
  • tool output: 1,000 토큰
  • model response: 800 토큰

이처럼 예산을 박아두고, 검색 결과는 토큰 기준으로 자르기가 필요합니다.

2) 저장 시점에 요약을 강제하지 말기

많은 팀이 저장할 때마다 LLM 요약을 돌리는데, 이러면 비용이 누적됩니다.

  • 원문은 artifact로 저장
  • episodic은 “짧은 사건 기록”만 저장
  • semantic은 사람이 만든 규칙이나 시스템 제약 위주로 저장

요약은 “검색 결과가 너무 길 때”에만, 그리고 “해당 스텝에서만” 수행하는 편이 비용 효율적입니다.

3) TTL과 압축 정책

  • episodic: 7~30일 TTL
  • semantic: TTL 없음(대신 변경 이력 관리)
  • artifact: 스토리지로 분리(S3 등)하고 벡터DB에는 포인터만

또한 동일한 내용이 반복 저장되는 것을 막기 위해, sha256 기반 중복 제거를 권장합니다.

환각을 더 줄이는 RAG 고급 패턴

1) “메모리 쓰기”도 검증 단계를 거치기

에이전트가 생성한 결론을 그대로 semantic memory로 올리면, 그 순간부터 환각이 영구 지식이 됩니다.

  • semantic 승격은 반드시 조건을 둡니다
    • 출처 2개 이상
    • 도구 결과(JSON, SQL 결과 등)로 재현 가능
    • 또는 사람이 승인

2) 리랭킹과 스코어 임계치

벡터 유사도는 “그럴듯함”이지 “정답”이 아닙니다.

  • score 임계치 미만이면 컨텍스트에 넣지 않기
  • 가능하면 cross-encoder 리랭커를 두어 top-k를 재정렬

3) 구조화 출력으로 근거-결론을 분리

모델 출력에 다음 필드를 강제하면, 환각 탐지가 쉬워집니다.

  • claims: 핵심 주장
  • evidence: 각 주장에 매핑되는 근거 id 또는 source
  • unknowns: 확인되지 않은 가정

구조화 출력에서 스키마 오류나 400을 자주 겪는다면 OpenAI Structured Outputs 400 해결 - JSON Schema도 함께 참고하세요.

예시 스키마(간단 버전):

{
  "type": "object",
  "properties": {
    "claims": { "type": "array", "items": { "type": "string" } },
    "evidence": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "claimIndex": { "type": "integer" },
          "sources": { "type": "array", "items": { "type": "string" } }
        },
        "required": ["claimIndex", "sources"]
      }
    },
    "unknowns": { "type": "array", "items": { "type": "string" } }
  },
  "required": ["claims", "evidence", "unknowns"]
}

실전 체크리스트: RAG 메모리 품질을 망치는 흔한 실수

  1. 청크가 너무 큼: 검색은 되지만 컨텍스트가 길어져 비용 증가
  2. 메타데이터가 없음: 출처 추적이 안 되어 환각 검증 불가
  3. taskId 격리가 없음: 다른 세션의 기억이 섞여 이상한 결론
  4. 검색 결과를 그대로 신뢰: score 임계치, 리랭킹, 다양성 전략 부재
  5. 도구 결과를 텍스트로만 저장: 구조화 데이터는 원문을 보존해야 재검증 가능

마무리: “기억”을 프롬프트에서 시스템으로 내리기

AutoGPT의 메모리 팽창과 환각은 모델 성능만의 문제가 아니라, 메모리를 다루는 시스템 설계 문제인 경우가 많습니다. RAG+벡터DB로 장기 기억을 외부화하면 다음 효과를 동시에 얻습니다.

  • 프롬프트 길이 안정화로 비용과 지연 감소
  • 근거 기반 답변으로 환각 감소
  • 작업/세션 단위 격리로 목표 드리프트 완화
  • 메모리 갱신 정책(TTL, 승격 조건)으로 품질 관리 가능

다음 단계로는 (1) 검색 품질 튜닝, (2) 리랭킹 도입, (3) semantic memory 승격 파이프라인에 사람 승인 또는 자동 검증을 붙이는 것을 추천합니다. 이 3가지만 갖춰도 “오래 돌릴수록 똑똑해지는 에이전트”에 훨씬 가까워집니다.