LangChain 메모리 테스트의 함정: 4시간을 허비하게 만든 두 가지 버그
요약
LangChain 메모리 컴포넌트 테스트 시 발생하는 상태 누수와 높은 반복 비용 문제를 해결하기 위한 Pytest 활용 전략을 다룹니다. 전체 체인이 아닌 메모리 객체를 격리하여 단위 테스트하는 설계 방식을 제안합니다.
핵심 포인트
- 수동 테스트의 한계인 높은 반복 비용과 상태 누수 문제 지적
- 전체 Chain 대신 메모리 인터페이스를 직접 격리하여 테스트할 것
- Pytest의 fixture와 parametrize를 활용한 효율적인 생명주기 관리
- LLM을 모킹하여 순수 로직 모듈로서의 메모리 검증
새벽 1시, 고객 그룹에서 불만이 터져 나왔습니다. 우리의 AI 지원 에이전트가 갑자기 기억상실증에 걸린 것입니다. 사용자가 방금 주문 번호를 알려주었는데, 바로 다음 차례에 봇이 "주문 번호를 말씀해 주시겠습니까?"라고 물었습니다. 코드의 여러 계층을 파헤친 끝에, 리팩터링(refactor) 과정에서 메모리 저장 로직이 실수로 변경되었다는 것을 발견했습니다. 그 순간 저는 결심했습니다. 메모리는 반드시 테스트해야 한다라고 말이죠. 하지만 Pytest를 사용하여 LangChain의 메모리 컴포넌트(memory components)를 테스트하는 것이 또 다른 오후 전체를 잡아먹을 줄은 예상치 못했습니다. 이 포스트는 여러분이 같은 실수를 반복하지 않도록 두 가지 실제 사례의 함정을 담고 있습니다.
메모리 테스트가 까다로운 이유
AI 에이전트의 대화 일관성(conversational coherence)은 전적으로 메모리에 달려 있습니다. LangChain은 ConversationBufferMemory, ConversationSummaryMemory 및 여러 다른 구현체들을 제공하지만, 대부분의 경우 우리는 Chain을 통해 간접적으로만 이를 사용합니다. 무언가 고장 나면, 우리는 수동으로 몇 차례의 대화 턴(dialogue turns)을 시뮬레이션하고 응답에 과거 정보가 포함되어 있는지 눈으로 확인합니다. 수동 테스트에는 두 가지 치명적인 결함이 있습니다:
- 높은 반복 비용: 코드를 변경할 때마다 멀티 턴(multi-turn) 대화를 처음부터 다시 재생하고 답변을 눈으로 직접 비교해야 합니다.
- 상태 누수 (Leaky state): 메모리는 상태를 가집니다(stateful). 한 번의 테스트 실행에서 발생한 대화가 다음 테스트로 누수되므로, 실패의 원인이 깨진 메모리 로직 때문인지 아니면 청소하지 않고 남겨둔 잔여물 때문인지 알 수 없습니다.
우리가 실제로 필요로 하는 것은 전체 Chain을 대상으로 하는 엔드 투 엔드(end-to-end) 복권 게임이 아니라, 다양한 대화 시퀀스에 걸쳐 메모리 컴포넌트의 출력을 직접 검증하는 반복 가능하고 자동 단언(auto-asserting)이 가능한 테스트 스위트입니다.
설계: Chain이 아닌 Memory를 테스트하라
저는 **Pytest + LangChain의 메모리 인터페이스(Memory interface)**를 사용하는 것으로 결론을 내렸으며, 핵심 아이디어는 메모리 객체를 격리하여 테스트하는 것입니다.
-
왜 Pytest인가?
pytest.mark.parametrize는 다양한 메모리 유형에 대해 동일한 테스트 스위트를 실행하는 데 완벽합니다.fixture는 메모리 인스턴스의 생명주기(lifecycle)를 우아하게 관리하여 테스트 간에 상태가 전이(bleed)되지 않도록 합니다. -
왜 전체 체인(Chain) 대신 메모리를 직접 테스트하는가?
체인(Chain)에는 느리고 불안정한 LLM도 포함되어 있습니다. 메모리는 본질적으로 대화 기록에서 컨텍스트(context)를 추출하는 순수 로직 모듈(pure-logic module)입니다.
load_memory_variables의 출력을 단위 테스트(Unit-testing)하는 것만으로도 충분합니다. 이것이 작동하고 LLM을 모킹(mock)한다면, 그 상위 레이어는 깨질 수 없습니다. -
왜 Unittest가 아닌가?
메모리 인스턴스를 빈번하게 생성하고 파괴해야 합니다. Pytest의 피스처(fixture) 메커니즘은
setUp/tearDown보다 더 유연하며, 파라미터화(parameterization)를 통해 단 한 번의 명령으로 6가지 메모리 유형을 다룰 수 있어 방대한 상용구 코드(boilerplate)를 절약할 수 있습니다.
핵심 구현: 처음부터 시작하는 자동화된 메모리 테스트
1. ConversationBufferMemory 테스트 – 기억된 내용 검증
이 테스트는 가장 단순한 버퍼 메모리(buffer memory)를 실행하며, 하나의 사용자 메시지를 시뮬레이션한 다음 반환된 컨텍스트에 해당 발화가 정확히 포함되어 있는지 확인(assert)합니다.
import pytest
from langchain.memory import ConversationBufferMemory
...
2. ConversationSummaryMemory 테스트 – 요약 생성 여부 검증
요약 메모리(Summary memory)는 기록을 압축하기 위해 LLM에 의존합니다. 테스트 속도를 유지하기 위해 LLM을 모킹(mock)합니다. 여기서는 LangChain의 FakeListLLM을 사용하여 고정된 요약을 반환하도록 합니다.
from langchain.memory import ConversationSummaryMemory
from langchain.llms.fake import FakeListLLM
...
이 과정은 밀리초 단위로 실행되며, OpenAI API 호출이 필요하지 않습니다.
함정: 내 인생의 4시간을 잡아먹은 두 가지 함정
함정 1: return_messages=True가 모든 문자열 단언(String Assertions)을 깨뜨림
발생한 상황:
ConversationBufferMemory에 return_messages=True를 설정한 후, assert "some word" in memory_vars["history"]와 같은 단언문(assertions)을 자신 있게 작성했습니다. 하지만 테스트는 모두 실패(red)했습니다. memory_vars["history"]를 출력해 보니 문자열이 아니라 [HumanMessage(content='...'), AIMessage(content='...')]와 같은 객체 리스트가 나타났습니다.
근본 원인:
return_messages=True로 설정하면, 메모리는 대화 기록을 미리 결합된 텍스트가 아닌 LangChain 메시지 객체(예: HumanMessage, AIMessage)로 반환합니다. 따라서 load_memory_variables의 출력값은 해당 메시지 객체 리스트를 포함하게 되며, 당연히 문자열에 대한 in 연산자 기반의 단언문은 실패하게 됩니다.
해결 방법:
문자열 매칭에 의존하는 테스트라면 return_messages=False를 유지하거나, 메시지 객체와 함께 작동하도록 단언문을 업데이트해야 합니다.
# 만약 return_messages=True가 활성화되어 있다면, 다음과 같이 단언해야 합니다
history = memory_vars["history"]
assert any("ABC123" in msg.content for msg in history)
단언문을 작성하기 전에 항상 history의 데이터 타입을 확인하세요. print(type(memory_vars["history"]))를 빠르게 실행해 보는 것만으로도 제 한 시간을 아낄 수 있었을 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기