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

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

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

AI Agent 的对话连贯性全看记忆。LangChain 内置了 ConversationBufferMemoryConversationSummaryMemory 等一堆实现,但大多数时候大家只通过 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 让字符串断言全部瘫痪

现象

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

原因

return_messages=True 会将内存中的对话以 LangChain 消息对象(如 HumanMessageAIMessage)的形式返回,不再是拼接好的文本。而 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

相关推荐
程序员黑豆1 小时前
鸿蒙应用开发:@Monitor 装饰器使用教程
前端·harmonyos
SamChan902 小时前
在Web应用中集成PDF多语言翻译功能:PDFTranslator API实战指南
前端·python·ai·pdf·yapi·机器翻译
kyriewen2 小时前
AI Agent 9秒删光了生产数据库——我给自己的项目做了5个紧急检查
前端·ai编程·claude
IT_陈寒2 小时前
JavaScript的this又双叒叕让我怀疑人生了
前端·人工智能·后端
陈随易2 小时前
MCP协议第5次更新,从打电话到微信聊天的巨大变革
前端·后端·程序员
omnijk3 小时前
前端工程化
前端
做前端的娜娜子3 小时前
前端必看!我把一段"能跑不敢动"的报表代码用 AI 重构成了组件(附完整 Prompt)
前端·ai编程
hunterandroid3 小时前
[鸿蒙从零到一] ArkUI 动画与转场实战:状态驱动、组件过渡与页面衔接
前端