LangChain 记忆测试踩坑实录:这两个坑让我排查了 4 小时

凌晨一点,客户群里炸了------我们的 AI 客服 Agent 突然"失忆",用户刚说完自己的订单号,下一轮它就反问:「请问您的订单号是多少?」。层层排查后发现,是记忆存储逻辑在一次重构中被误改。那时我就下定决心:记忆必须被测试。万万没想到,用 Pytest 测试 LangChain 的记忆组件,竟让我又搭进去一整个下午。这篇文章就记录两个真实的大坑,让你别再掉进去。

问题拆解:记忆测试到底难在哪

AI Agent 的对话连贯性全看记忆。LangChain 内置了 ConversationBufferMemory、ConversationSummaryMemory 等一堆实现,但大多数时候大家只通过 Chain 间接使用,出了问题就靠手动跑几段对话来验证。手动测试有两个致命伤:

  1. 重复成本高:每次改完代码,你得从零模拟多轮对话,肉眼比对回复是否包含历史信息。
  2. 状态残留:记忆是有状态的,上一轮测试的对话会污染下一轮,你永远无法确定失败是因为记忆逻辑挂了,还是因为你上次没清干净。

我们真正需要的,是一套可重复、自动断言的测试方案,能直接验证记忆组件在各种对话序列下的输出,而不是把整个 Chain 拉起来做端到端侥幸检查。

方案设计:为什么不测 Chain,只测 Memory

我选择了 Pytest + LangChain Memory 接口 的组合,核心思路是隔离测试记忆对象本身。

  • 为什么选 Pytest?

    pytest.mark.parametrize 太适合对不同记忆类型做同一套测试;fixture 能优雅管理记忆实例的生命周期,避免状态污染。

  • 为什么直接测 Memory 而不是整条 Chain?

    Chain 里还包着 LLM,调用慢且不稳定。记忆本质就是一个"从对话历史提取上下文"的纯逻辑模块,用单元测试验证它的 load_memory_variables 输出完全足够。只要它对了,再加上 LLM 的 Mock,上层就不会崩。

  • 为什么不选 Unittest?

    我要频繁创建、销毁记忆实例,Pytest 的 fixture 机制比 setUp/tearDown 更灵活,而且参数化可以一键覆盖 6 种记忆类型,省去大量重复代码。

核心实现:从 0 搭建记忆自动化测试

1. 测试 ConversaionBufferMemory:验证"记住"了什么

这段代码测试最基础的缓冲记忆,模拟用户说了一句话,然后断言记忆返回的上下文是否包含这句话。

python 复制代码
import pytest
from langchain.memory import ConversationBufferMemory

# fixture 保证每个测试拿到全新的记忆实例
@pytest.fixture
def buffer_memory():
    # return_messages=False 让 load_memory_variables 返回纯字符串,方便断言
    return ConversationBufferMemory(return_messages=False)

def test_buffer_memory_remembers_input(buffer_memory):
    """一轮对话后,记忆应包含用户输入"""
    # 模拟一轮完整的对话上下文
    buffer_memory.save_context(
        {"input": "我的订单号是ABC123"},
        {"output": "好的,已为您查询到订单信息"}
    )
    # 提取记忆变量
    memory_vars = buffer_memory.load_memory_variables({})
    
    # 断言历史字符串包含用户原话
    assert "ABC123" in memory_vars["history"]
    assert "Human: 我的订单号是ABC123" in memory_vars["history"]

2. 测试 ConversationSummaryMemory:验证摘要是否生成

摘要记忆依赖 LLM 自动压缩历史,通常我们 Mock 掉 LLM 来保证速度。这里用 LangChain 的 FakeListLLM 返回固定摘要。

python 复制代码
from langchain.memory import ConversationSummaryMemory
from langchain.llms.fake import FakeListLLM

@pytest.fixture
def summary_memory():
    # Fake LLM 直接返回预设摘要,不依赖真实模型调用
    llm = FakeListLLM(responses=["用户询问了订单ABC123的情况"])
    return ConversationSummaryMemory(llm=llm, return_messages=False)

def test_summary_memory_returns_fake_summary(summary_memory):
    summary_memory.save_context(
        {"input": "我的订单号是ABC123"},
        {"output": "查询中..."}
    )
    memory_vars = summary_memory.load_memory_variables({})
    # 摘要应为 Fake LLM 返回的内容
    assert "用户询问了订单ABC123的情况" in memory_vars["history"]

这样,我们可以在毫秒级完成测试,完全不用调 OpenAI API。

踩坑记录:折磨我 4 小时的两个坑

坑 1:return_messages=True 让字符串断言全部瘫痪

现象 :

我把 ConversationBufferMemory 的 return_messages 设为 True,然后自信地写下一堆 assert "某个词" in memory_vars["history"]。测试跑起来,全红。打印 memory_vars["history"] 一看,显示的是 [HumanMessage(content='...'), AIMessage(content='...')] 对象列表,而不是字符串。

原因 :

return_messages=True 会将内存中的对话以 LangChain 消息对象(如 HumanMessage、AIMessage)的形式返回,不再是拼接好的文本。而 load_memory_variables 文档里对返回类型的说明只有一句话,很容易忽略。官方示例里,大多数情况用的都是默认 False,一旦你需要消息对象做更细粒度的断言,就很容易踩这个类型陷阱。

解决 :

统一测试策略------如果只是验证历史是否包含某些关键词,全部用 return_messages=False。如果确实需要消息对象,则改用属性断言:

python 复制代码
messages = memory_vars["history"]
assert any("ABC123" in msg.content for msg in messages)

坑 2:Pytest fixture 的"半共享"状态导致记忆泄露

现象 :

我为多个测试编写了同一个 buffer_memory fixture,scope 用的是默认的 function,按理每个测试都应该拿到新实例。结果 test_A 执行后,test_B 却能记住 test_A 的对话历史,断言上下文长度时就崩了。

原因 :

排查半天发现,我在 fixture 里用了一个模块级的字典缓存,本意是为了避免重复创建 LLM 实例,结果随手把 memory 实例也放进了那个字典:

python 复制代码
_memory_cache = {}

def get_memory():
    if "buffer" not in _memory_cache:
        _memory_cache["buffer"] = ConversationBufferMemory()
    return _memory_cache["buffer"]   # ❌ 永远返回同一个对象

fixture 虽然每次被调用,但内部逻辑却返回了单例对象,导致多个测试共享同一个 memory,历史消息越积越多。

解决 :

删掉缓存,或者只缓存无状态组件(如 LLM 配置)。如果确实需要复用 LLM,可以单独用一个 session 级别的 fixture 提供 LLM,而 memory fixture 每次重新构造:

python 复制代码
@pytest.fixture(scope="session")
def llm():
    return FakeListLLM(responses=["fake"])

@pytest.fixture  # scope="function" 默认
def memory(llm):
    return ConversationSummaryMemory(llm=llm)  # 每次都新对象

官方文档没强调的一个点是:LangChain 的 Memory 对象内部持有 chat_memory,它是非线程安全的列表,测试里必须严格隔离。

效果验证:从 5 分钟到 2 秒

自动化测试落地后,我对 6 种记忆组件一共覆盖了 32 个用例,包括空历史、多轮追加、摘要阈值触发、token 限制裁剪等场景。结果如下:

指标 手动测试 自动化测试
单轮回归耗时 ≈5 分钟 2.3 秒(全部)
覆盖的记忆类型 2 种(顺手测) 6 种
发现的隐藏 Bug 0 3 个
CI 集成 ❌ 不可能 ✅ 已集成

现在每次提交代码,Github Actions 都会自动跑这 32 个测试,再也没出现过记忆悄悄失灵的问题。

可直接用的代码

下面是一个开箱即用的记忆测试 fixture 模板,替换 memory_class 和预期值即可套用到你的项目里:

python 复制代码
import pytest
from langchain.memory import ConversationBufferMemory, ConversationSummaryMemory
from langchain.llms.fake import FakeListLLM

@pytest.fixture(params=[ConversationBufferMemory, ConversationSummaryMemory])
def memory(request):
    if request.param == ConversationSummaryMemory:
        return ConversationSummaryMemory(llm=FakeListLLM(responses=["fake summary"]))
    return request.param()

关于作者

我是宝富,一个专啃后端硬骨头的实战派架构师,喜欢用自动化测试给项目上保险。

GitHub:github.com/baofugege --- 后续会把 AI Agent 测试模板整理成开源工具。

Sponsor:github.com/sponsors/ba... --- 如果这篇文章帮你省下 4 小时,不妨请我喝杯咖啡。

提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege

#Python #LangChain #AI Agent #自动化测试 #Pytest

相关推荐
Lank_M几秒前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写2 分钟前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李4 分钟前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易18 分钟前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马29 分钟前
前沿视界
前端
kyriewen34 分钟前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易1 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员
IT_陈寒1 小时前
Vue的这个响应式陷阱,差点让我加班到天亮
前端·人工智能·后端
程序员大阳2 小时前
HTML+CSS+JS对话式实战(9)-项目总结与问题分析
javascript·css·html·实战·项目总结
Flynt2 小时前
别写拖拽行为,只声明"能拖到哪":UIST 最佳论文的拖拽方案我实测了
前端·svg·preact