凌晨1:37,同事在群里发了个截图:AI助手把用户三个月前的过敏史当成了今天的新症状,建议“立即停药”。我后背一凉——这是我们长期记忆模块上线后最严重的事故。
事后复盘,根因是记忆去重逻辑有一个边界bug,但更让我后怕的是:这个bug,我们已经有3轮手工测试,全都漏了。不是测试不到位,是人脑追不动状态组合爆炸。那天晚上我决定,必须把记忆一致性的测试完全自动化。两天后,用pytest搭起来的测试套件一次性爆出11个问题,其中3个是P0级。这篇文章,就是我踩过的坑和完整的代码。
问题拆解:为什么手工测试搞不定AI长期记忆?
我们的场景很简单:用户与AI对话,AI需要记住用户说过的事实(偏好、健康信息、日程等),并在后续回答中准确引用。长期记忆模块暴露四个核心操作:添加、查询、更新、删除,外加自动过期和容量上限。
一致性风险点长这样:
- 同一个key两次写入,第二次是覆盖还是追加?
- 更新不存在的key,是静默失败还是抛异常?
- 容量满时删除最旧记忆,但刚好有多条「最旧」(同时间戳),删谁?
- 过期记忆被查询时,是过滤掉还是连带着把有效记忆也弄丢?
过去我们靠Excel用例跑回归:手动启动一个最小化环境,在代码里插 print 看返回值。一轮30分钟,跑完眼都花了,还只覆盖了主路径。人工测试最大的陷阱不是慢,是重构后根本不想再测。我们需要一套能在5秒内完整回归的自动化测试,让记忆模块像数据库一样可靠。
方案设计:pytest + 内存实现 + 参数化地狱
技术选型毫无悬念是pytest。不用unittest的原因:参数化写起来太啰嗦,fixture的生命周期控制也不够细。我们的核心策略是:把被测对象压到最纯粹的内存实现,先把逻辑一致性测穿,再谈集成。
被测对象是一个类 MemoryStore,接口如下:
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噪音。
测试组织三把斧:
- fixture 提供干净的store实例和各种预置状态。
- 参数化 覆盖所有关键路径和边界条件,包括异常输入。
- 间接参数化 搭配fixture,精确控制预置数据。
没有用mock。记忆模块的逻辑本身就是纯函数式的,mock反而会掩盖掉真实的状态流转。
核心实现:从最基础到最容易踩坑的测试
1. 基础CRUD一致性
这段代码验证最朴素的增删改查,同时测了同key重复添加时的覆盖行为。
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没有更新访问位置,导致用户刚问过的信息下一秒就被挤走。
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。
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:参数化中列表对象被多测试修改
我们有个参数化测试,想测不同容量下淘汰行为:
@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,一行命令跑起来:
pytest -v test_memory.py
如果你用的是 poetry,记得在 dev 组添加pytest。
#Python #AI工程 #pytest #测试自动化 #长期记忆
关于作者
一个在后端和AI工程之间反复横跳的实战派开发者,坚信“能自动化的绝不手工”。写代码,也写让同行少踩坑的文章。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你省下了一天的调试时间,请我喝杯咖啡
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege