LangChain Memory 测试踩坑实录:用 pytest 自动化回归对话记忆,我折腾了整整一个周末

上周五临下班,产品经理幽幽地飘过来:"咱们那个 AI 客服,怎么老记不住用户上一轮说了啥?上个月明明修好了,刚才又翻车。"我打开日志一看,果然是对话记忆在某个分支场景下被意外清空了------这已经是本月第三次了。

事后复盘,根因不在于 LangChain 的 Memory 机制本身有什么大坑,而是我们缺乏一套能自动回归对话记忆的测试体系。每次改 prompt、切模型、调 chain 参数,都靠手工跑几句对话,根本覆盖不全多轮对话的边界情况。于是我花了整整一个周末,把 LangChain Memory 的自动化回归测试从 0 到 1 啃了下来。

这篇文章,就记录我如何用 pytest 搭起一套可复用的 Memory 回归测试方案,以及中间踩过的那些"官方文档没告诉你"的坑。

问题拆解:为什么手工测试 Memory 总是漏

大模型应用里的 Memory,本质上是一个在对话过程中持续写入和读取的状态存储。它不像传统 API 那样 input-output 确定------同一段对话历史,不同模型、不同 temperature,甚至相同模型不同时刻的回复都可能不同。这就导致两个难点:

  1. 记忆内容非确定性:你不能简单断言"第 N 轮之后 memory 里一定存了某段 exact 文本",因为 LLM 可能会用自己的话改写。
  2. 记忆边界难触发:Token 限制裁剪、摘要压缩、多用户隔离......这些逻辑在简单手工测试里几乎走不到分支。

常规的方案是 mock LLM,但这又背离了回归测试的本质------我们想抓的恰恰是真实 LLM 环境下记忆行为是否退化 。因此目标很明确:用真实 LLM(或可控的本地模型)跑多轮对话,然后对 Memory 的状态做语义级断言,而非字符级断言。

方案设计:为什么不直接用 LangChain 自带的测试工具

LangChain 社区版几乎没有现成的 Memory 测试套件。几条路摆在面前:

  • 方案 A:用 LangSmith 的 Evaluation 功能。一来收费,二来线上依赖重,不适合本地 CI/CD 快速反馈。
  • 方案 B:自己写一套基于 unittest 的脚本。能跑,但扩展性差,参数化一轮对话要写大量重复代码。
  • 方案 C:pytest + fixture + 自定义断言。pytest 的 fixture 体系天然适合管理共享的 chain 和 memory 对象;自定义断言配合少量语义匹配逻辑,可以写出"memory 中应包含用户地址信息"这类高可读性的用例。

最终选定方案 C。架构上拆成三层:

  • Fixture 层:负责初始化 LLM(这里用本地 Ollama 的 qwen2 模型保证稳定可复现)、不同类型 Memory(ConversationBufferMemory、ConversationSummaryMemory 等)。
  • Step 层:封装一轮"用户输入 → 模型输出 → memory 更新"的交互,返回本轮状态快照。
  • Assert 层:通过 LLM 再一次做简单的是/否判断,或者用关键实体提取来做语义断言。

核心实现:从 fixture 到语义断言,逐步拆解

1. 这是 fixture 与基础对话函数,解决"如何复用模型和 memory"的问题

下面的 conftest.py 把模型和 memory 的初始化抽离出来,每个测试用例都可以按需注入不同参数。

python 复制代码
# conftest.py
import pytest
from langchain_community.chat_models import ChatOllama
from langchain.memory import ConversationBufferMemory, ConversationSummaryMemory
from langchain.chains import ConversationChain

@pytest.fixture
def ollama_model():
    # 固定参数,确保本地回归结果可重复
    return ChatOllama(
        model="qwen2:7b",
        temperature=0,       # 确定性输出,方便对比
        top_p=0.1,
    )

@pytest.fixture(params=["buffer", "summary"])
def memory(request, ollama_model):
    """参数化 fixture:一次性覆盖多种 Memory 实现"""
    if request.param == "buffer":
        return ConversationBufferMemory(return_messages=True)
    elif request.param == "summary":
        # 使用模型做摘要,这里传入同一个 ollama_model
        return ConversationSummaryMemory(
            llm=ollama_model,
            return_messages=True,
        )

接下来是一个通用的对话函数,它接受已构建好的 chain 和用户输入,执行一轮交互并返回更新后的 memory 内容。

python 复制代码
# conftest.py (续)
def run_turn(chain: ConversationChain, user_input: str) -> dict:
    """执行一轮对话,并返回 memory 中的消息列表"""
    _ = chain.run(user_input)                # 忽略模型回复
    # 直接从 chain.memory 读取全部历史消息
    messages = chain.memory.load_memory_variables({})["history"]
    return {
        "message_count": len(messages),
        "last_human_msg": user_input,
        "history_text": " ".join([m.content for m in messages]),
    }

2. 第一个测试:验证基础记忆能跨轮次保持

这段代码解决最核心的回归断言------"用户说过的事,后续轮次 memory 里到底还在不在"。

python 复制代码
# test_memory_retention.py
import pytest
from langchain.chains import ConversationChain

@pytest.mark.parametrize("memory", ["buffer", "summary"], indirect=True)
def test_cross_turn_memory_retention(ollama_model, memory):
    chain = ConversationChain(llm=ollama_model, memory=memory)

    # 第一轮:明确告知关键信息
    run_turn(chain, "我叫张三,我下周二要去北京出差。")

    # 第二轮:故意引入噪音,试探记忆是否被冲掉
    run_turn(chain, "今天天气真不错,适合出去玩。")

    # 第三轮:返回去追问第一轮的信息
    result = run_turn(chain, "我之前告诉过你我要去哪里出差?什么时候?")

    # 语义断言:memory 中应包含"北京"和"下周"或"周二"
    assert "北京" in result["history_text"]
    assert any(word in result["history_text"] for word in ["周二", "下周", "星期二"])

注意,这里用的是简单的关键词断言。对于 buffer memory,关键词命中率极高;对于 summary memory,因为模型会压缩表述,我用 any(...) 做一个宽松匹配。这也是后续要进化为语义断言的地方。

3. 第二个测试:验证 Memory 在上下文超长时的裁剪行为

官方文档对 max_token_limit 的描述一嘴带过,实际落地才发现,消息裁剪的时机和策略在不同参数下差异非常大。下面这个用例专门用来回归"旧信息被移除后,新信息不应受影响"。

python 复制代码
# test_memory_trimming.py
import pytest
from langchain.chains import ConversationChain
from langchain.memory import ConversationTokenBufferMemory
from langchain_community.chat_models import ChatOllama

@pytest.fixture
def token_model():
    return ChatOllama(model="qwen2:7b", temperature=0)

def test_token_buffer_trimming_not_losing_recent_context(token_model):
    # 设置极小的 token 上限,强制触发裁剪
    memory = ConversationTokenBufferMemory(
        llm=token_model,
        max_token_limit=40,          # 故意设低,模拟极端场景
        return_messages=True,
    )
    chain = ConversationChain(llm=token_model, memory=memory)

    # 连续塞入大段信息,迟早触发 token 裁剪
    run_turn(chain, "我的信用卡号是 1234-5678-9012-3456")
    run_turn(chain, "我家的地址是上海市浦东新区张江高科技园区某某路 100 号")
    run_turn(chain, "我刚想起来,卡号后面还有安全码 321")

    # 最后追问:理论上信用卡号和地址只能保留其一(token 限制)
    result = run_turn(chain, "你还能查到我的信用卡号吗?")

    # 核心断言:最近输入的"安全码"一定在,因为它至今仍在 token 窗口内
    assert "321" in result["history_text"]
    # 可选宽松断言:信用卡号或者地址至少有一个被保留(或部分保留)
    assert ("1234" in result["history_text"] or "张江" in result["history_text"])

踩坑记录

坑1:load_memory_variables({}) 在 SummaryMemory 下返回空字典

现象 :第一轮对话结束后立即调用 memory.load_memory_variables({})ConversationSummaryMemory 返回的字典里 history 键对应的值竟是空字符串,导致所有断言失败。

原因ConversationSummaryMemory 只有在第二次 调用 chain 时才会真正触发摘要并存入 moving_summary_buffer。首轮对话结束后,摘要变量尚未生成。

解决 :在所有测试中,至少跑两轮对话后才开始做 memory 状态断言。这个 behavior 在源码里藏得很深,官方示例也只用 predict 方式演示,初次接触极易踩坑。

坑2:ConversationTokenBufferMemory 的 token 计数依赖 LLM 回调

现象 :用 ChatOllama 初始化 ConversationTokenBufferMemory,设置了 max_token_limit=100,但跑了 20 轮对话都没触发裁剪。

原因ConversationTokenBufferMemory 内部通过 get_num_tokens_from_messages 计算 token 数,默认调用 tiktoken 编码器。但 ChatOllama 模型是 qwen,它的 tokenizer 与 tiktoken 不匹配,导致每次都返回估算值 0,裁剪逻辑彻底失效。

解决 :强行注入一个自定义的 count_tokens 函数,简单用字符数/2 粗略估算,或者干脆换用原生支持 token 计数的模型(如 OpenAI API)。我在测试中用 ChatOpenAI 替代本地模型跑这部分用例,确保计数准确。

效果验证

在没有这套测试之前,我们发版后 Memory 相关的缺陷反馈平均要3 天 才能从用户渠道漏出;接入 CI 后,每次改动在 5 分钟内就能跑完 12 个核心回归用例,覆盖 buffer/summary/token-buffer 三种 Memory 类型和 4 个典型对话场景(个人信息保持、上下文裁剪、多用户隔离、摘要质量)。

指标 优化前 优化后
回归周期 手工测试,>2 小时 自动化,5 分钟
缺陷发现阶段 生产环境 CI 阶段
可覆盖 Memory 类型 1 种(buffer) 3 种

可直接用的代码/工具

我把上面的 fixture 和关键测试用例打包成了一个独立的 test_memory.py 模板,你只需要替换成自己的 LLM 实例,再根据业务场景补充断言逻辑,就能立刻在项目里跑起来。运行只需一行:

bash 复制代码
pytest -v test_memory.py --tb=short

#Python #LLM #LangChain #测试 #自动化回归

关于作者

我是宝富,一个在 AI 应用落地和系统稳定性上反复横跳的后端/架构程序员。除了写代码,我还喜欢琢磨怎么让大模型在生产环境中更"靠谱"。

GitHub: github.com/baofugege

Sponsor: github.com/sponsors/ba... --- 如果这篇文章让你少熬一个周末,可以请我喝杯咖啡。

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

相关推荐
kyriewen1 小时前
Claude Code后天起默认Auto模式了——我第一时间改了这6个设置
前端·ai编程·claude
风骏时光牛马2 小时前
智能赋能型AI办公一体化系统架构
前端
满栀5852 小时前
vue动态路由效果
前端·javascript·vue.js·前端框架·vue
土豆~2 小时前
文件名没后缀就打不开:前端文件预览的内容嗅探改造
前端·状态模式
Euato_Key3 小时前
全栈视野学习Cookie——理解 Cookie 的本质
前端
前端开发呀3 小时前
我的 AI 终端配置清单
前端
敲代码的玉米C3 小时前
测试一直在写你的真实数据根
前端·人工智能·架构
jjw_zyfx4 小时前
css vue vite实现闪烁的呼吸效果
javascript·css·vue.js