把AI长期记忆测试从手动验证换成pytest,2天揪出11个隐藏Bug

凌晨1:37,同事在群里发了个截图:AI助手把用户三个月前的过敏史当成了今天的新症状,建议"立即停药"。我后背一凉------这是我们长期记忆模块上线后最严重的事故。

事后复盘,根因是记忆去重逻辑有一个边界bug,但更让我后怕的是:这个bug,我们已经有3轮手工测试,全都漏了。不是测试不到位,是人脑追不动状态组合爆炸。那天晚上我决定,必须把记忆一致性的测试完全自动化。两天后,用pytest搭起来的测试套件一次性爆出11个问题,其中3个是P0级。这篇文章,就是我踩过的坑和完整的代码。

问题拆解:为什么手工测试搞不定AI长期记忆?

我们的场景很简单:用户与AI对话,AI需要记住用户说过的事实(偏好、健康信息、日程等),并在后续回答中准确引用。长期记忆模块暴露四个核心操作:添加查询更新删除,外加自动过期和容量上限。

一致性风险点长这样:

  • 同一个key两次写入,第二次是覆盖还是追加?
  • 更新不存在的key,是静默失败还是抛异常?
  • 容量满时删除最旧记忆,但刚好有多条「最旧」(同时间戳),删谁?
  • 过期记忆被查询时,是过滤掉还是连带着把有效记忆也弄丢?

过去我们靠Excel用例跑回归:手动启动一个最小化环境,在代码里插 print 看返回值。一轮30分钟,跑完眼都花了,还只覆盖了主路径。人工测试最大的陷阱不是慢,是重构后根本不想再测。我们需要一套能在5秒内完整回归的自动化测试,让记忆模块像数据库一样可靠。

方案设计:pytest + 内存实现 + 参数化地狱

技术选型毫无悬念是pytest。不用unittest的原因:参数化写起来太啰嗦,fixture的生命周期控制也不够细。我们的核心策略是:把被测对象压到最纯粹的内存实现,先把逻辑一致性测穿,再谈集成

被测对象是一个类 MemoryStore,接口如下:

python 复制代码
class MemoryStore:
    def add(self, user_id, key, value, ttl=None): ...
    def get(self, user_id, key): ...
    def update(self, user_id, key, value): ...
    def delete(self, user_id, key): ...
    def get_all(self, user_id): ...  # 按时间倒序

基础存储结构是 defaultdict 内嵌有序字典,维护插入顺序,配合时间戳实现LRU淘汰和TTL。测试时直接实例化这个类,不碰任何数据库,隔离掉网络和IO噪音。

测试组织三把斧:

  1. fixture 提供干净的store实例和各种预置状态。
  2. 参数化 覆盖所有关键路径和边界条件,包括异常输入。
  3. 间接参数化 搭配fixture,精确控制预置数据。

没有用mock。记忆模块的逻辑本身就是纯函数式的,mock反而会掩盖掉真实的状态流转。

核心实现:从最基础到最容易踩坑的测试

1. 基础CRUD一致性

这段代码验证最朴素的增删改查,同时测了同key重复添加时的覆盖行为。

python 复制代码
import pytest
from datetime import datetime, timedelta
from collections import defaultdict, OrderedDict
import time

# ---- 被测实现(简化版,放这里保证可运行) ----
class MemoryStore:
    def __init__(self, capacity=100):
        self.capacity = capacity
        self._store = defaultdict(OrderedDict)  # user_id -> OrderedDict(key -> (value, expire_at))
    
    def add(self, user_id, key, value, ttl=None):
        expire_at = datetime.now() + timedelta(seconds=ttl) if ttl else None
        if key in self._store[user_id]:
            # 重复key:移动到末尾(更新访问时间),覆盖值
            self._store[user_id].move_to_end(key)
        elif len(self._store[user_id]) >= self.capacity:
            # 淘汰最旧的
            self._store[user_id].popitem(last=False)
        self._store[user_id][key] = (value, expire_at)
    
    def get(self, user_id, key):
        entry = self._store[user_id].get(key)
        if entry is None:
            return None
        value, expire_at = entry
        if expire_at and datetime.now() > expire_at:
            del self._store[user_id][key]  # 惰性删除
            return None
        self._store[user_id].move_to_end(key)  # 更新访问时间,影响LRU
        return value
    
    def update(self, user_id, key, value):
        entry = self._store[user_id].get(key)
        if entry is None:
            raise KeyError(f"Key '{key}' not found for user {user_id}")
        _, expire_at = entry
        self._store[user_id][key] = (value, expire_at)
    
    def delete(self, user_id, key):
        self._store[user_id].pop(key, None)
    
    def get_all(self, user_id):
        now = datetime.now()
        result = []
        # 倒序遍历,同时惰性删除过期数据
        for key in reversed(list(self._store[user_id].keys())):
            value, expire_at = self._store[user_id][key]
            if expire_at and now > expire_at:
                del self._store[user_id][key]
                continue
            result.append((key, value))
        return result


# ---- 测试代码 ----
@pytest.fixture
def store():
    """每个测试用例拿到一个全新的store,避免状态污染"""
    return MemoryStore(capacity=5)

def test_basic_crud(store):
    # 添加
    store.add("u1", "color", "blue")
    assert store.get("u1", "color") == "blue"
    
    # 更新
    store.update("u1", "color", "red")
    assert store.get("u1", "color") == "red"
    
    # 删除
    store.delete("u1", "color")
    assert store.get("u1", "color") is None

def test_duplicate_key_should_overwrite(store):
    store.add("u1", "key", "v1")
    store.add("u1", "key", "v2")
    assert store.get("u1", "key") == "v2"
    # 重复add不应增加条目计数,应该还是1条
    assert len(store.get_all("u1")) == 1

2. 容量满时的LRU淘汰逻辑

这里最容易出错:淘汰时到底淘汰的是"最旧添加"还是"最旧访问"?我们的 get 操作会调用 move_to_end(key),意味着被访问过的记忆不应该被淘汰。下面这个测试直接抓住了我们生产环境的一个P0 Bug------最初版的get没有更新访问位置,导致用户刚问过的信息下一秒就被挤走。

python 复制代码
def test_lru_eviction_most_recently_used_survives(store):
    """容量满时,最久未被访问的条目被淘汰,最近被访问的保留"""
    # 填满容量为5的store
    for i in range(5):
        store.add("u1", f"k{i}", f"v{i}")
    # 访问k0,使其成为最近使用
    store.get("u1", "k0")
    # 再插入新key,触发淘汰
    store.add("u1", "new", "new_value")
    
    all_keys = [k for k, v in store.get_all("u1")]
    assert "k0" in all_keys           # k0因为被访问过,应该存活
    assert "new" in all_keys
    assert "k1" not in all_keys       # k1是插入后从未访问的最旧条目,应被淘汰

3. TTL过期与边界时间

TTL过期看起来简单,但"刚好过期那一刻"的行为非常容易出歧义。我们用的是 datetime.now() > expire_at,所以过期瞬间的查询应该返回None。这个测试用例用 time.sleep 精确控制时间窗口,同时验证惰性删除是否真的删掉了底层数据,而不仅是返回None。

python 复制代码
def test_ttl_expiry(store):
    store.add("u1", "temp", "data", ttl=1)  # 1秒后过期
    assert store.get("u1", "temp") == "data"
    time.sleep(1.1)  # 等待过期
    assert store.get("u1", "temp") is None
    # 惰性删除后,底层应无残留,get_all也不应返回该条目
    assert len(store.get_all("u1")) == 0

def test_ttl_edge_expiry_exactly_at_boundary(store):
    """测试刚好在过期边界的行为:过期后立即插入同名key,应视为全新条目"""
    store.add("u1", "k", "v", ttl=0)  # 立即过期
    time.sleep(0.1)  # 确保时间已经过去
    # 过期后添加同名key
    store.add("u1", "k", "v2")
    assert store.get("u1", "k") == "v2"

踩坑记录:那些官方文档没告诉你的pytest细节

坑1:fixture作用域用错,测试之间互相"下毒"

一开始我把 store 的fixture设成 scope="session",想着复用对象省时间。结果一个测试里修改了store的数据,后面所有用到的用例全炸了------不是炸在同一个用例里,而是随机挂在不同地方。现象诡异,查了两小时才发现是状态泄漏。教训 :除非被测对象是无状态的,否则fixture作用域必须为 function,让每个测试独享一个实例。参数化测试共享同一个fixture也是安全的,因为pytest会为每个参数组合重跑fixture。

坑2:参数化中列表对象被多测试修改

我们有个参数化测试,想测不同容量下淘汰行为:

python 复制代码
@pytest.mark.parametrize("capacity,keys_to_add,expected_evicted", [
    (3, ["a","b","c","d"], "a"),
])

开始把 keys_to_add 直接用列表字面量(可变对象),一个用例里不小心 append 了一下,结果影响到了后续其他参数化组合------Python的默认参数共享问题。解决很简单:用元组代替列表,或者使用 ids 显式标记不可变数据。

坑3:时间相关的测试在CI上偶发失败

sleep(1.1) 在CI流水线有时因为机器负载高,实际睡眠超过1.1秒,导致测试不稳定。最后改成后台注入可控的"虚拟时钟"太麻烦,我们折中方案是:将TTL设为2秒,sleep(2.2),并给 datetime.now 加上一个小的容忍窗口。更好的做法是使用 freezegun 冻结时间,但引入额外依赖,目前2秒的宽松窗口足够。

效果验证

指标 手工测试 pytest自动化
全量回归耗时 ~30分钟 5.2秒
覆盖用例数 12条 47条(含边界/异常)
发现隐藏Bug - 11个(3个P0)
开发重构信心 心虚不敢动 改完跑一下,5秒后见真章

最明显的变化是:团队开始主动往记忆模块加新功能,因为测试套件就像安全网一样兜底。

可直接用的代码

将上面的 MemoryStore 类和测试复制到 test_memory.py,一行命令跑起来:

bash 复制代码
pytest -v test_memory.py

如果你用的是 poetry,记得在 dev 组添加pytest。


#Python #AI工程 #pytest #测试自动化 #长期记忆


关于作者

一个在后端和AI工程之间反复横跳的实战派开发者,坚信"能自动化的绝不手工"。写代码,也写让同行少踩坑的文章。

GitHub: github.com/baofugege

Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你省下了一天的调试时间,请我喝杯咖啡

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

相关推荐
lichenyang4532 小时前
从 Vite 空项目到 AIGC 图片工作台:我如何打通生图、任务轮询、生成库与 Canvas 动态特效
前端·人工智能
泡沫冰@2 小时前
上章节中文件的讲解
前端·网络·nginx
进击的丸子3 小时前
APP人脸识别增值版Harmony Demo实操与关键代码解析
前端·程序员·harmonyos
a1117763 小时前
唯美花朵风格的黑胶唱片音乐播放器
前端·css·css3
Hilaku3 小时前
工作 5 年后,决定你薪资上限的究竟是什么?
前端·javascript·程序员
爱分享的程序猿-Clark3 小时前
【前端分享】vue3 有 keep-alive属性吗?
前端
Revolution613 小时前
页面更新后为什么出现 Loading chunk failed:旧页面如何请求了已删除的构建产物
前端·面试·前端工程化
JavaGuide4 小时前
GitHub 9.8 万 Star!把整个代码仓库变成知识图谱,这个 AI Coding 工具太适合 Claude Code / Codex 了
前端·后端·ai编程
hunterandroid4 小时前
[鸿蒙从零到一] HarmonyOS 通知与提醒实战:消息发布、点击跳转与定时触达
前端