Published on

LangChain 메모리 누수·토큰폭탄 7가지 차단법

Authors

서버가 멀쩡히 돌다가 어느 순간 RAM이 꾸준히 차오르고, 응답이 점점 느려지며, 비용이 폭증하는 경우가 있습니다. LangChain을 붙인 뒤 이런 증상이 나타났다면 대부분은 메모리 객체가 의도치 않게 커지거나, 프롬프트에 과거 대화/문서가 무제한으로 누적되거나, RAG 검색 결과가 과다 포함되거나, 콜백/트레이싱이 과하게 로그를 쌓는 형태로 발생합니다.

이 글은 “왜 커지는지”를 추상적으로 설명하는 대신, 실무에서 가장 흔한 메모리 누수·토큰폭탄 7가지 패턴을 짚고, 각각을 코드로 차단하는 방법을 정리합니다.

아래 내용은 Python 기준이며 LangChain 버전에 따라 클래스/모듈 경로가 조금씩 다를 수 있습니다. 중요한 건 “패턴과 방어 장치”입니다.

0. 먼저: 누수와 토큰폭탄을 구분하는 3가지 신호

둘은 같이 오기도 하지만 원인이 다를 수 있습니다.

  • 토큰폭탄 신호
    • LLM 호출당 입력 토큰이 선형 또는 기하급수로 증가
    • 같은 질문인데 점점 느려짐
    • 비용(토큰 과금)이 급증
  • 메모리 누수 신호
    • 트래픽이 비슷한데 RSS 메모리가 지속 상승
    • 워커 재시작 시 메모리 정상화
    • GC 이후에도 객체가 남아 있음
  • 둘 다 의심되는 신호
    • 대화가 길어질수록 RAM도 늘고 토큰도 늘어남

관측을 붙이면 원인 분리가 쉬워집니다. 분산 추적과 비용/지연 상관관계 분석은 OpenTelemetry로 MSA 분산 트랜잭션 추적 실전 같은 방식이 특히 효과적입니다.

1) 무제한 ConversationBufferMemory 사용 금지: 윈도우·요약으로 교체

가장 흔한 토큰폭탄입니다. ConversationBufferMemory 류를 그대로 쓰면 대화가 길어질수록 프롬프트에 과거 전체가 붙습니다. “메모리”라는 이름 때문에 안전해 보이지만, 사실상 무한 append 입니다.

차단법 A: 최근 K턴만 유지(윈도우)

from langchain.memory import ConversationBufferWindowMemory

# 최근 6턴만 유지
memory = ConversationBufferWindowMemory(
    k=6,
    return_messages=True,
)

차단법 B: 요약 메모리(토큰 상한을 요약으로 흡수)

from langchain.memory import ConversationSummaryBufferMemory
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

memory = ConversationSummaryBufferMemory(
    llm=llm,
    max_token_limit=1200,  # 이 한도를 넘으면 요약으로 압축
    return_messages=True,
)

핵심은 “대화가 길어져도 입력 토큰이 상수 또는 완만하게 증가”하도록 만드는 것입니다.

2) Retrieval 결과를 통째로 프롬프트에 붙이지 말기: k·길이·중복 제거

RAG에서 토큰폭탄이 터지는 전형적인 흐름은 이렇습니다.

  • 검색 k 값이 너무 큼
  • 각 문서 chunk가 너무 김
  • 중복 chunk가 섞여 들어옴
  • 결과를 그대로 stuff 방식으로 전부 붙임

차단법 A: 검색 k를 작게, chunk 길이를 제한

retriever = vectorstore.as_retriever(
    search_kwargs={
        "k": 4,  # 20, 50 같은 값은 비용 폭탄이 되기 쉬움
    }
)

chunk 전략과 인덱스 튜닝으로 “적은 k로도 정확도를 유지”하는 게 정석입니다. Qdrant를 쓴다면 Qdrant HNSW 튜닝으로 RAG 지연 50% 줄이기처럼 리콜-지연 균형을 맞추는 방법이 도움이 됩니다.

차단법 B: 문서 길이 하드 컷(문서당 토큰/문자 상한)

def clamp_text(s: str, max_chars: int = 2000) -> str:
    s = s or ""
    return s[:max_chars]

# retriever 결과를 프롬프트에 넣기 전에 길이 제한
clamped_docs = []
for d in docs:
    d.page_content = clamp_text(d.page_content, max_chars=2000)
    clamped_docs.append(d)

차단법 C: 중복 제거(동일 chunk 반복 삽입 방지)

def dedup_docs(docs):
    seen = set()
    out = []
    for d in docs:
        key = (d.metadata.get("source"), d.metadata.get("chunk_id"), hash(d.page_content))
        if key in seen:
            continue
        seen.add(key)
        out.append(d)
    return out

3) stuff 체인 남용 금지: map-reduce·refine·compression으로 전환

검색 결과를 한 번에 “전부 붙이는” 방식(일명 stuff)은 구현이 쉽지만 토큰폭탄에 가장 취약합니다.

차단법: 컨텍스트 압축 리트리버(요약/필터로 문서량 감소)

from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor
from langchain_openai import ChatOpenAI

base_retriever = vectorstore.as_retriever(search_kwargs={"k": 8})

compressor_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
compressor = LLMChainExtractor.from_llm(compressor_llm)

retriever = ContextualCompressionRetriever(
    base_retriever=base_retriever,
    base_compressor=compressor,
)

이 방식은 “검색은 넉넉히, 프롬프트 투입은 압축해서”라는 전략입니다. 비용은 약간 늘 수 있지만, 최종 프롬프트 토큰을 통제하기 쉬워지고 응답 안정성이 올라갑니다.

4) 콜백/트레이싱이 로그를 메모리에 쌓는 패턴 차단

LangChain 콜백은 편리하지만, 다음 패턴이 겹치면 메모리 누수처럼 보일 수 있습니다.

  • 요청마다 콜백 핸들러를 새로 만들고 전역 리스트에 누적
  • 토큰 스트리밍 이벤트를 전부 메모리에 append
  • 디버그 모드에서 prompt/response 전문을 계속 보관

차단법: 요청 스코프 컨텍스트로 수명 관리

Python에서 “요청 단위로 생성하고 끝나면 정리”를 강제하려면 컨텍스트 매니저가 가장 안전합니다. 관련 패턴은 Python 데코레이터·컨텍스트 매니저로 로깅·트랜잭션 중복 제거와 동일한 철학입니다.

from contextlib import contextmanager

@contextmanager
def request_scope_callbacks():
    events = []

    def on_event(e):
        # 무한 저장 금지: 마지막 N개만 유지
        events.append(e)
        if len(events) > 200:
            del events[:-200]

    try:
        yield on_event
    finally:
        # 강한 참조 제거
        events.clear()

핵심은 “관측 데이터도 무한히 쌓이면 그것도 누수”라는 점입니다.

5) 스트리밍 응답에서 큐/버퍼 무제한 증가 방지

SSE나 WebSocket 스트리밍을 붙이면, 생산자(LLM 토큰 생성)가 소비자(클라이언트 수신)보다 빠를 때 버퍼가 쌓입니다. 네트워크가 느린 사용자 한 명이 워커 메모리를 잡아먹는 형태가 됩니다.

차단법: bounded queue와 타임아웃

import asyncio

queue = asyncio.Queue(maxsize=200)  # 무제한 금지

async def producer(stream):
    async for token in stream:
        try:
            await asyncio.wait_for(queue.put(token), timeout=0.5)
        except asyncio.TimeoutError:
            # 소비가 너무 느리면 중단 또는 드롭 정책
            break

async def consumer(send):
    while True:
        token = await queue.get()
        await send(token)
        queue.task_done()

정책은 서비스 성격에 따라 다릅니다.

  • 드롭(일부 토큰 유실) 허용 가능하면 드롭
  • 유실 불가면 “느린 연결은 끊고 재시도”가 낫습니다

6) 에이전트/툴 루프 폭주 차단: 반복 상한, 도구 출력 길이 제한

Agent를 쓰면 토큰폭탄이 “대화 누적”이 아니라 “반복 호출”로도 옵니다.

  • 툴이 긴 출력을 반환(예: HTML 전문, 로그 전문)
  • 에이전트가 그 출력을 다시 프롬프트에 넣고 재추론
  • 실패 시 재시도 루프가 길어짐

차단법 A: 반복 상한 설정

LangChain 에이전트 실행 시 max_iterations 같은 상한을 반드시 둡니다.

from langchain.agents import AgentExecutor

agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=False,
    max_iterations=5,
    handle_parsing_errors=True,
)

차단법 B: 툴 출력 하드 컷

def clamp_tool_output(text: str, max_chars: int = 4000) -> str:
    text = text or ""
    return text[:max_chars]

# 툴 함수 내부에서 반환 직전에 적용
def my_tool(...):
    raw = expensive_operation()
    return clamp_tool_output(raw, max_chars=4000)

“툴 출력은 LLM에게 주는 컨텍스트”이므로, 길이 제한이 곧 비용 제한입니다.

7) 캐시/세션/전역 객체로 인한 참조 누수: TTL·LRU·약한 참조

서비스를 만들다 보면 다음을 전역으로 두고 싶어집니다.

  • 사용자별 memory 딕셔너리
  • 세션별 체인/리트리버 객체
  • 최근 요청 결과 캐시

문제는 “사용자가 늘면 메모리도 무한히 늘어나는 구조”가 되기 쉽다는 점입니다.

차단법 A: TTL 캐시로 세션 자동 만료

from cachetools import TTLCache

# 사용자 세션 메모리를 30분 TTL로 자동 정리
session_memory = TTLCache(maxsize=10_000, ttl=60 * 30)

def get_memory(session_id: str):
    if session_id not in session_memory:
        session_memory[session_id] = ConversationBufferWindowMemory(k=6, return_messages=True)
    return session_memory[session_id]

차단법 B: LRU로 상한 고정

from cachetools import LRUCache

chains = LRUCache(maxsize=500)  # 500개 넘으면 오래된 것부터 제거

TTL과 LRU는 성격이 다르니 보통 둘 중 하나만 넣기보다, “세션은 TTL”, “무거운 객체 캐시는 LRU”처럼 섞어 씁니다.

실전 체크리스트: 배포 전 10분 점검

  • 메모리
    • 대화 메모리는 k 또는 max_token_limit이 있는가
    • 세션 저장소는 TTL 또는 LRU로 상한이 있는가
    • 콜백/이벤트/로그 버퍼는 무한 append가 아닌가
  • 토큰
    • RAG 검색 k가 과도하지 않은가
    • 문서 chunk 길이 상한이 있는가
    • stuff 방식으로 모든 문서를 붙이지 않는가
    • 툴 출력 길이 제한이 있는가
    • Agent 반복 횟수 상한이 있는가
  • 운영
    • 요청별 토큰 사용량과 지연을 함께 관측하는가
    • “느린 스트리밍 소비자”에 대한 차단 정책이 있는가

마무리: “상한을 걸면” 대부분 해결된다

LangChain에서 발생하는 메모리 누수·토큰폭탄의 공통점은 상한이 없는 누적입니다. 대화, 문서, 툴 출력, 이벤트 로그, 세션 캐시, 스트리밍 버퍼 모두 “조금만 쌓이겠지”로 시작하지만, 운영 환경에서는 결국 무한히 커집니다.

이 글의 7가지 차단법은 모두 같은 결론으로 수렴합니다.

  • 입력에 상한(k, max_token_limit, max_chars, max_iterations)
  • 저장소에 상한(TTL, LRU)
  • 요청 스코프 수명 관리(컨텍스트 매니저)

이 3가지만 일관되게 적용해도, LangChain 서비스는 비용과 안정성 측면에서 체감이 크게 좋아집니다.