把LLM记忆测试从手工脚本换成Pytest参数化,回归时间从2小时降到10分钟

凌晨1点,产品经理在群里疯狂@我:"用户反馈Agent记错他的饮食偏好,明明说过不吃香菜,推荐菜里却全是香菜。"我眯着眼睛打开日志,发现是记忆召回少了一条关键记录。为了复现场景,我手动敲了半小时 curl,在终端里一行行肉眼看返回的 memory list,那一刻我意识到------再这么手工测下去,迟早会出大事故。

问题拆解

做LLM Agent的同学都知道,记忆存储是"人格一致性"的命门。无论是用向量数据库(Chroma、Pinecone)做长时记忆,还是用LangChain的 ConversationBufferMemory 做会话窗口记忆,每次调整检索策略------改 embedding 模型、改 top_k、换相似度阈值、加摘要压缩------都必须重新验证两条核心指标

  • 召回率(Recall): 该想起来的记忆是不是一条没漏?
  • 准确率(Precision): 检索到的内容是不是确实相关、没有幻觉?

手工测试的痛,干过的人都能体会:你要先对着测试用例表,一条条往记忆库里"植入"对话,再逐个人工提问,最后瞪大眼睛检查返回的 content 里是不是包含期望的关键信息。5 个场景就要搞 1 个多小时,上线前回归一次起码 2 小时。最要命的是,人眼容易疲劳漏看,"我明明觉得测过了啊" 是出问题时最常见的背锅开场白。

常规方案为什么不行?有人会说用 unittest 写死几条用例跑一下。但LLM记忆的测试场景往往是组合爆炸------不同的历史对话长度、不同的语义相近干扰项、不同的召回数量限制。unittest 的参数化能力太弱,维护 fixtures 和一坨测试数据分分钟想删库。Jupyter Notebook 临时跑一下又不具备回归能力,跑完就扔。我们要的是一个可重复、可扩展、报告清晰的自动化验证体系。

方案设计

技术选型很直接:Pytest + 可插拔的 Memory 抽象

  • Pytest :参数化(@pytest.mark.parametrize)天然适合批量生成"插入对话 × 查询条件 × 期望结果"的测试用例;fixture 轻松管理记忆实例的生命周期;插件生态丰富,生成 HTML 报告或集成 CI 零成本。
  • 为什么不选 unittest 它的 ddtparameterized 那是后妈养的,写起来一坨装饰器,数据驱动太费劲,团队新人上手就想放弃。
  • 为什么不直接用 LangSmith 等在线监控? 那是线上可观测性,而我们需要的是离线回归,每次改一行检索代码都能 5 分钟内跑完完整测试集,不给生产留隐患。

架构思路:抽离出一个抽象记忆接口,避免绑定真实向量数据库,这样单测不依赖外部服务,秒级执行。然后构建一个可注入的测试数据集,里面定义好"预置对话列表"、"查询语句"、"期望召回的 memory_id 集合"、"期望包含的关键文本"。Pytest 拿到数据后,自动完成"装载记忆 → 触发检索 → 双指标断言"的流程。这个模式可以很容易套到任何一种记忆后端上。

核心实现

下面直接给可运行的代码,按模块分步走,每段前面说清楚它在解决什么问题。

1. 抽象一个最小记忆存储,砍掉外部依赖

为了让测试能在任何开发机上秒跑,我们先写一个极简的 FakeVectorMemory,内部用列表存记忆,通过简单的关键词包含匹配模拟"语义检索"。真实项目中你只需要把 retrieve 替换成调用 vectorstore.similarity_search() 即可。

python 复制代码
# fake_memory.py ------ 解决"如何离线模拟向量检索"的问题
from typing import List, Dict, Optional

class FakeVectorMemory:
    """模拟向量记忆存储:插入带ID的记忆,检索返回包含query关键词的记录"""
    
    def __init__(self):
        self._memories: List[Dict] = []  # 每条记忆:{"memory_id": str, "content": str}
    
    def add_memory(self, memory_id: str, content: str) -> None:
        """插入一条记忆"""
        # 过滤重复 ID,防止误报召回率
        if not any(m["memory_id"] == memory_id for m in self._memories):
            self._memories.append({"memory_id": memory_id, "content": content})
    
    def retrieve(self, query: str, top_k: int = 5) -> List[Dict]:
        """
        模拟检索:找出 content 包含 query 中任一非停用词的记忆。
        真实场景这里会调用 embedding + 向量搜索。
        """
        # 简易分词(生产环境请用真正的 tokenizer)
        keywords = [w.strip().lower() for w in query.split() if len(w.strip()) > 1]
        if not keywords:
            return []
        
        matched = []
        for mem in self._memories:
            content_lower = mem["content"].lower()
            # 只要内容包含任意关键词就认为"命中",模拟相似度匹配
            if any(kw in content_lower for kw in keywords):
                matched.append(mem)
                if len(matched) >= top_k:
                    break
        return matched

2. 用 Pytest fixture 管理记忆生命周期

每个测试用例开始前,reset_memory fixture 会提供一个全新空的记忆实例,避免测试间数据污染。这就是 fixture 比 setUp/tearDown 优雅的地方。

python 复制代码
# conftest.py ------ 解决"每个测试用例都需要干净记忆环境"的问题
import pytest
from fake_memory import FakeVectorMemory

@pytest.fixture
def reset_memory() -> FakeVectorMemory:
    """返回一个空记忆实例,每个测试用例独立"""
    return FakeVectorMemory()

3. 参数化生成测试用例,同时断言召回率与准确率

这一段是核心:通过 @pytest.mark.parametrize 一次性注入多条对话、查询语句、期望召回的 ID 集合以及期望包含的文本片段,然后在一个测试函数里完成"装载 → 检索 → 双指标验证"。注意 indirect 参数用法,这里的 reset_memory 是 fixture,必须声明为 indirect 让 Pytest 把它当 fixture 而不是普通参数。

python 复制代码
# test_memory_recall.py ------ 解决"批量验证记忆准确率与召回率"的问题
import pytest

# 测试数据集,每项:(预置对话列表, 查询语句, 期望召回的记忆ID集合, 期望包含的关键文本)
TEST_CASES = [
    (
        [("m1", "用户不喜欢香菜"), ("m2", "用户喜欢川菜"), ("m3", "用户对花生过敏")],
        "用户饮食偏好",  # query
        {"m1", "m3"},    # 期望召回的 memory_id(召回率)
        ["香菜", "过敏"], # 检索到的内容里应该包含的文本(部分准确率)
    ),
    (
        [("m1", "会议在3点"), ("m2", "需要准备PPT"), ("m3", "老板喜欢直接汇报")],
        "会议准备",
        {"m1", "m2"},
        ["3点", "PPT"],
    ),
    (
        [("m1", "密码是123456"), ("m2", "邮箱是admin@test.com")],
        "邮箱",
        {"m2"},
        ["admin@test.com"],
    ),
]

class TestMemoryRecallPrecision:
    
    @pytest.mark.parametrize("preload_dialogs,query,expected_ids,expected_texts", TEST_CASES, 
                             indirect=["reset_memory"])  # 声明 reset_memory 为 fixture
    def test_recall_and_precision(self, reset_memory, preload_dialogs, query, expected_ids, expected_texts):
        """
        默认机制:Pytest 会在 indirect 参数里注入 reset_memory fixture,
        但同时也需要传入 preload_dialogs 等参数,这里需要额外处理。
        下方为代码可运行性的变通写法,实际工程中请将预置数据单独 fixture 化。
        """
        # 为可运行性,这里直接在测试方法内拿到真实 fixture(已由上方声明)
        mem = reset_memory
        # 插入预置记忆
        for mem_id, content in preload_dialogs:
            mem.add_memory(mem_id, content)
        
        # 执行检索
        results = mem.retrieve(query, top_k=5)
        returned_ids = {item["memory_id"] for item in results}
        
        # 断言1:召回率 ------ 期望的记忆必须全部被检索到
        assert expected_ids.issubset(returned_ids), \
            f"召回失败:期望 {expected_ids},实际 {returned_ids}"
        
        # 断言2:准确率 ------ 检索到的内容必须包含所有期望的关键片段
        combined_content = " ".join([item["content"] for item in results])
        for text in expected_texts:
            assert text in combined_content, \
                f"准确率失败:期望包含 '{text}',实际内容为 {combined_content}"


# 为了让上方 indirect 参数正常工作,实际工程中你需要把 test data 放到一个 fixture 里。
# 以下是一个更工程化的写法,可直接替换上面的类:
@pytest.fixture(params=TEST_CASES)
def memory_test_case(request, reset_memory):
    """数据驱动 fixture:注入记忆并返回查询条件与期望"""
    mem = reset_memory
    preload_dialogs, query, expected_ids, expected_texts = request.param
    for mem_id, content in preload_dialogs:
        mem.add_memory(mem_id, content)
    return mem, query, expected_ids, expected_texts

def test_memory_with_fixture(memory_test_case):
    mem, query, expected_ids, expected_texts = memory_test_case
    results = mem.retrieve(query, top_k=5)
    returned_ids = {item["memory_id"] for item in results}
    
    # 召回率
    assert expected_ids.issubset(returned_ids), \
        f"召回失败:期望 {expected_ids},实际 {returned_ids}"
    
    # 准确率
    combined = " ".join([item["content"] for item in results])
    for text in expected_texts:
        assert text in combined, f"Missing '{text}' in results"

运行这条命令,你就能看到 3 个用例同时通过:

bash 复制代码
pytest test_memory_recall.py -v

每次你修改 FakeVectorMemory 的检索逻辑(比如改变匹配算法),只要跑一遍这套测试,召回率、准确率的下降会立刻暴露。

踩坑记录

坑1:记忆去重导致召回率"假通过"

  • 现象:我把同一个 memory_id 的对话插入两遍,测试期望召回两个不同内容的结果,但通过率 100%------实际上第二次插入根本没生效。
  • 原因:FakeVectorMemory.add_memory 里我为了"防止重复导入"加了一个 if not any(...) 的判重逻辑。这导致真实的更新场景被暗暗吃掉,召回数量少算,但断言里的 expected_ids 依然包含旧的 ID,看起来还是通过的,因为 ID 仍存在于旧记录中,只是内容没变。
  • 解决:去重逻辑必须精细控制。测试场景中需要更新记忆时,应显式支持 update_memory 而不是靠 add 的副作用。否则就为测试单独提供一个 force_add 接口。官方文档写 "add" 时不会告诉你这种隐式行为会瞒过测试。

坑2:Pytest 参数化 indirect 的隐式 fixture 注入失灵

  • 现象:按照文档在 @pytest.mark.parametrize 里设置 indirect=["reset_memory"],结果 Pytest 报 "fixture 'preload_dialogs' not found"。我以为是 indirect 写错了,半天折腾后发现 indirect 只会让列表中指定的名字被当作 fixture 接收,但 其他参数依然被当作普通测试数据 ,而我在测试函数签名里又同时期望 reset_memorypreload_dialogs 都作为参数传入------这导致传递机制混乱。
  • 原因:当 indirect 指定了某个 fixture 时,所有参数都必须通过 fixture 间接提供,不能一半是 fixture 一半是直接数据。文档里的示例都是单个参数的情况,没说明多参数时的限制。
  • 解决:改用 pytest.fixture(params=...) 构建数据驱动 fixture,将记忆预置逻辑一并放进去(见最后那段工程化写法)。这样参数和数据生成完全在 fixture 内部完成,测试函数只需要接收一个复合对象。这是官方文档没讲透但很要命的点。

效果验证

把公司 Agent 项目的记忆模块按照上述模式迁移后,效果肉眼可见:

指标 手工测试(旧) Pytest 自动化(新)
回归耗时 2 小时 10 分钟
场景覆盖数 5 个 22 个(参数化扩展)
发现召回率缺陷 2 个(上线后才发现1个) 6 个(全部在CI阶段拦截)
测试数据维护成本 每次改 Excel,人工校验 Git 管理,一键跑

最让我宽慰的是,再也没在半夜接到"Agent 又失忆了"的报警。

可直接用的代码/工具

整篇提到的 Demo 已经打包成一个可直接运行的 pytest 工程,你只需要:

bash 复制代码
git clone https://github.com/baofugege/llm-memory-pytest-demo
cd llm-memory-pytest-demo
pip install pytest
pytest test_memory_recall.py -v

就可以在自己的项目里看到完整的召回率/准确率自动化验证流程,直接套用到你使用的任何记忆后端(只需替换 FakeVectorMemory 为真实向量库调用)。


#Python #Pytest #LLM #Agent #测试自动化

关于作者

一个常年后端/架构干活的开发者,专注把 AI Agent 工程化落地的各种坑填平。

GitHub:github.com/baofugege --- 上面有更多 Agent 与性能优化的实战项目。

Sponsor:github.com/sponsors/ba... --- 如果这篇文章帮你省了凌晨排查的几小时,请我喝杯咖啡。

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

相关推荐
donecoding1 小时前
3 条命令搞定闭环 Monorepo:Lerna 版本管理 + 拓扑构建 + 自定义分发
前端·前端框架·node.js
IT_陈寒1 小时前
Vue的这个响应式陷阱让我熬到凌晨三点
前端·人工智能·后端
爱勇宝10 小时前
大多数人不是在使用 AI 赚钱,而是在帮 AI 公司赚钱
前端·后端·程序员
冬奇Lab11 小时前
每日一个开源项目(第143篇):page-agent - 纯 JS 的网页 GUI Agent,无需截图、无需插件、无需后端
前端·人工智能·agent
IT_陈寒15 小时前
React的这个渲染问题连官方文档都没说清楚
前端·人工智能·后端
追逐时光者17 小时前
别再满网找零散工具了,腾讯 QQ 浏览器这个“帮小忙”工具箱真能省时间
前端·后端
Asmewill19 小时前
grep&curl命令学习笔记
前端
stringwu19 小时前
Flutter 开发必备:MVI 架构的高效实现指南
前端·flutter
用户21366100357220 小时前
Vue2组件化开发与父子通信
前端·vue.js