DEV Community

BAOFUFAN
BAOFUFAN

Posted on

The Ghost Conversation: A 6-Hour Debugging Journey into LangChain Memory Storage Tests

At 1 a.m., after merging a seemingly “harmless” PR, the CI pipeline blew up—three test cases failed at once, all pointing to lost conversation history. The strangest part? Running pytest -s locally passed with flying colors. I stared at the keys in Redis and suddenly noticed that inside a session’s message history there was an extra user message that didn’t belong to that session. That moment made my hair stand on end: our chatbot could be quietly injecting fragments of User A’s conversation into User B’s session in production.

That’s the deepest rabbit hole I’ve fallen into when writing automated tests for LangChain memory storage with pytest.

Breaking down the problem

Our scenario was straightforward: a LangChain‑based customer service bot using ConversationBufferMemory to store conversation history in Redis, ensuring consistent context across turns. The requirement was dead simple—memory must not be lost or mixed up. Yet standard unit tests could not catch the bugs:

  • Tests hardcoded a fixed session_id, so they never covered concurrent multi‑session scenarios.
  • The dev environment shared the same Redis instance; local tests from different developers polluted each other’s keys.
  • LangChain’s memory_key, input_key, and output_key are designed to be extremely flexible. As soon as they don’t match the variable names inside a Chain, the history silently becomes an empty dictionary—with no error at all.

The root cause was clear: LangChain’s memory reads and writes rely on “loose matching + default serialization”. There is no strict schema, nor any built‑in isolation testing utilities. You think it’s saved, but because of a key‑name mismatch it was never stored. You think you’ve retrieved it, but extra attributes on message objects may be quietly discarded during deserialization. Mocks at the storage layer simply cannot surface these issues—only real Redis + multi‑session concurrency tests can reproduce the real‑world “ghost conversations”.

Designing the solution

Goal: use pytest to build an isolated, repeatable, production‑like memory storage test environment that ensures:

  1. Each test case gets its own independent session_id with zero cross‑contamination.
  2. We can simulate concurrent writes and verify history consistency after multiple reads.
  3. We cover adding, deleting, and querying messages, as well as the boundaries of serialization/deserialization.

Why weren’t other approaches chosen?

  • Mocking Redis: serialization problems would never surface. LangChain internally uses json.dumps/loads, and custom message classes immediately lose fidelity under a mock.
  • Using LangChain’s InMemoryChatMessageHistory: that’s a non‑persistent toy; it won’t reveal real storage bugs.
  • Manual connection management: far too easy to leave dirty data outside a finally block, causing flaky tests.

Finally we landed on: pytest + redis (a real instance) + factory_boy to generate random session_ids. Fixtures in conftest.py handle the creation and cleanup of RedisChatMessageHistory objects uniformly. Each test function receives a session_id with a random prefix and automatically cleans up its keys after use, completely eliminating pollution.

Core implementation

1. Foundation: isolated Redis memory fixture

This code ensures every test case gets an independent session and that everything is cleaned up automatically when the test finishes.

# conftest.py
import pytest
import redis
from uuid import uuid4
from langchain_community.chat_message_histories import RedisChatMessageHistory

@pytest.fixture(scope="function")
def redis_client():
    """真实 Redis 客户端,指向测试库(db=1)避免干扰开发库"""
    r = redis.Redis(host='localhost', port=6379, db=1, decode_responses=True)
    yield r
    # 保险起见,清理所有测试遗留 key(通常不会走到这里,靠 fixture 清理)
    r.flushdb()

@pytest.fixture
def session_id():
    """随机 session_id,带测试前缀方便排查"""
    return f"test:{uuid4().hex[:8]}"

@pytest.fixture
def chat_history(redis_client, session_id):
    """每条测试用例独立的消息历史对象"""
    history = RedisChatMessageHistory(
        session_id=session_id,
        url="redis://localhost:6379/1"
    )
    yield history
    # 测试结束后精准删除自己的 key,不会干扰其他 session
    redis_client.delete(session_id)
Enter fullscreen mode Exit fullscreen mode

Note: This uses Redis database 1 for testing isolation. In a real project you should inject a REDIS_TEST_URL environment variable.

2. Basic test: add messages then read them back, verifying consistent history

This test validates that stored messages can be retrieved completely and in the correct order.

# test_memory_basic.py
from langchain_core.messages import HumanMessage, AIMessage

def test_add_and_retrieve_messages(chat_history):
    # 模拟一轮对话
    chat_history.add_user_message("我想退订单 #12345")
    chat_history.add_ai_message("好的,请提供您的注册手机号后四位。")

    messages = chat_history.messages

    assert len(messages) == 2
    assert isinstance(messages[0], HumanMessage)
    assert messages[0].content == "我想退订单 #12345"
    assert isinstance(messages[1], AIMessage)
    assert messages[1].content == "好的,请提供您的注册手机号后四位。"
Enter fullscreen mode Exit fullscreen mode

Once this test passed on CI, we thought we were in the clear—until we connected ConversationBufferMemory and the history still occasionally turned up empty. That led to a second test.

3. Integration test: read and write through ConversationBufferMemory, verifying key mapping

This code addresses the issue where the variable names used by Memory and the Chain must align; otherwise the history ...

# test_memory_integration.py
import pytest
from langchain.memory import ConversationBufferMemory
from langchain_core.messages import HumanMessage, AIMessage

@pytest.fixture
def memory(chat_history):
    """Wrap the real Redis history into a ConversationBufferMemory instance"""
    return ConversationBufferMemory(
        chat_memory=chat_history,
        memory_key="chat_history",     # 必须与 Chain 的输入变量名一致
        input_key="input",            # 用户输入的变量名
        output_key="output",          # 模型输出的变量名
        return_messages=True
    )

def test_save_and_load_context(memory):
    # Simulate saving context as a Chain would
    memory.save_context(
        {"input": "我想退订单 #12345"},
        {"output": "好的,请提供您的注册手机号后四位。"}
    )

    # Load the stored variables
    loaded = memory.load_memory_variables({})
    assert "chat_history" in loaded
    messages = loaded["chat_history"]

    assert len(messages) == 2
    assert messages[0].content == "我想退订单 #12345"
    assert messages[1].content == "好的,请提供您的注册手机号后四位。"

    # Verify that the Redis key is not accidentally cleared
    # ...
Enter fullscreen mode Exit fullscreen mode

Top comments (0)