凌晨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? 它的ddt、parameterized那是后妈养的,写起来一坨装饰器,数据驱动太费劲,团队新人上手就想放弃。 - 为什么不直接用 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_memory和preload_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