凌晨两点十七分,Slack 里炸出一条用户反馈:"你们的 AI 助理像得了健忘症,刚说完的会议纪要,再问就说没存过。"翻日志发现,写入 Redis 的消息 ACK 了,但向量库里那一条竟然是空的。这种"间歇性失忆"已经第三次把用户惹毛了。之前我们只靠手动 curl 几条对话就算测试通过,显然挂了。我被迫在第二天早晨立了一个规矩:所有记忆存储的状态一致性逻辑,必须由 Pytest 自动化验证,否则不准合入主干。
问题拆解
大模型的"记忆存储"通常不止一个后端:短期记忆放在 Redis List 或内存环形缓冲区,长期记忆被 Embedding 后存入向量数据库(如 Qdrant / Milvus),同时可能还有一份原始文本落在 PostgreSQL。一次 save_memory 调用,如果只是顺序写了 Redis 和 PG,然后异步推向量库,那么在 Redis 已返回但向量库未落盘的窗口期,读请求可能拿到不完整的历史。
更恶心的是"记忆压缩"------当对话轮数超过阈值,触发摘要生成,同时要清理原始消息、更新摘要向量。这个过程中任何一个步骤失败,都会造成"已压缩"和"原消息残留"并存,导致读到重复或矛盾的内容。
常规方案是加一个 verify 步骤手动检查,或线上靠对账脚本扫脏数据。这有三个致命伤:
- 异步窗口期的 bug 几乎不可能手复现,靠人眼检查日志就是在大海捞针。
- 对账脚本是事后擦屁股,不是开发阶段的防护。
- 新来的同事根本不知道哪些场景会翻车,"可工作"的假象一戳就破。
所以我决定直接把状态一致性的验证写进 Pytest 测试套件,让 CI 替我们跑。
方案设计
选型上,测试框架没得商量就是 Pytest,原因很简单:fixture 能优雅地管理多个存储后端的生命周期,parametrize 能把几十种异常组合一网打尽。
至于为什么不用容易想到的单元测试:
- 单元测试只 mock 单层 ,验证不了跨存储系统的并发竞态。
mock出来的返回值永远是按你预设的,测不出真实窗口。 - 集成测试用 Docker-compose 拉起真 Redis / 向量库 ,但 Pytest 里直接管理它们要写一堆 boilerplate。我们用
pytest-docker或者干脆用testcontainers-python,能让每个测试用例拥有独立且隔离的存储实例,跑完自动销毁。
架构思路很简单:每个测试用例是一个并发写 + 多次读 + 最终断言 的模式。我们模拟两个协程同时写入相同用户的消息,再施加"压缩"触发信号,最后用 assert_eventually 风格的轮询去验证所有后端最终一致。没有用 time.sleep(3) 这种拍脑袋的等待,而是用 tenacity 的 retry 或 asyncio.wait_for 做带超时的条件等待。
另外我专门把"记忆存储接口"定义成 Protocol,这样测试不光覆盖当前实现,以后换了向量库或缓存层,同样是这批用例跑一遍就行。
核心实现
1. 定义可测试的记忆存储抽象
这段代码解决"究竟要测什么接口"的问题------把产品级的记忆存储抽象成一个 MemoryStoreProtocol,后面所有测试只依赖这个协议。
python
from typing import Protocol, List, Dict, Any, runtime_checkable
@runtime_checkable
class MemoryStoreProtocol(Protocol):
"""大模型记忆存储必须实现的接口"""
async def append_message(self, user_id: str, role: str, content: str) -> None:
"""追加一条对话消息到短期记忆"""
...
async def get_history(self, user_id: str, limit: int = 10) -> List[Dict[str, Any]]:
"""按时间顺序返回最近的消息列表"""
...
async def compact(self, user_id: str) -> None:
"""触发记忆压缩:生成摘要并清理原始短期消息"""
...
async def consistent_state(self, user_id: str) -> bool:
"""检查所有后端数据是否一致(用于测试断言)"""
...
2. 一个带竞态条件的简化实现
为了让测试有意义,我故意写一个"有问题的"实现 FragileMemoryStore------它用 Python list 代替 Redis,用 dict 代替向量库,并在写入时随机 await asyncio.sleep(0) 模拟异步延迟。这段代码是测试的靶子。
python
import asyncio
import random
from dataclasses import dataclass, field
@dataclass
class FragileMemoryStore:
# 模拟三层存储
short_term: Dict[str, List[dict]] = field(default_factory=dict) # Redis List
vector: Dict[str, List[str]] = field(default_factory=dict) # 向量库
raw_pg: Dict[str, List[dict]] = field(default_factory=dict) # PG 原始表
async def append_message(self, user_id: str, role: str, content: str) -> None:
msg = {"role": role, "content": content}
# 1. 先写短期缓存(同步写)
self.short_term.setdefault(user_id, []).append(msg)
# 2. 写 PG 原始表(同步写)
self.raw_pg.setdefault(user_id, []).append(msg)
# 3. 推向量库是异步的,模拟延迟
await asyncio.sleep(random.uniform(0, 0.02)) # 随机窗口
self.vector.setdefault(user_id, []).append(content)
async def get_history(self, user_id: str, limit: int = 10) -> List[dict]:
# 读的时候优先短期记忆,但如果没有就退化到 raw_pg
if user_id in self.short_term:
return self.short_term[user_id][-limit:]
return self.raw_pg.get(user_id, [])[-limit:]
async def compact(self, user_id: str) -> None:
if user_id not in self.short_term:
return
# 压缩:生成一个假的摘要
summary = f"Summary of {len(self.short_term[user_id])} msgs"
# 写入向量库并清空短期,但可能出现部分失败
self.vector[user_id] = [summary]
# 模拟清空短期和 raw 的原子性问题
await asyncio.sleep(random.uniform(0, 0.01))
self.short_term.pop(user_id, None)
# 故意不删 raw_pg,制造不一致
# self.raw_pg.pop(user_id, None) # 这就是 bug 所在
async def consistent_state(self, user_id: str) -> bool:
short = len(self.short_term.get(user_id, []))
raw = len(self.raw_pg.get(user_id, []))
vec = len(self.vector.get(user_id, []))
# 简单一致性:短期 + raw 应该为 0 当且仅当已压缩,否则向量库应有原始内容
if short == 0 and raw > 0 and vec == 1:
return False # 压缩后 raw 还有数据,不一致
return True
3. Pytest 并发测试用例
这段代码用 pytest-asyncio 同时发起两个写协程和一个压缩操作,然后轮询等待系统达到一致状态,超时则认定状态一致性被打破。
python
import pytest
import asyncio
from tenacity import retry, stop_after_delay, wait_fixed
# 待测实现和协议
from store import FragileMemoryStore, MemoryStoreProtocol
@pytest.fixture
async def store():
"""每个测试用例独立的存储实例"""
s = FragileMemoryStore()
yield s
# 清理(这里可忽略,因为每次新建)
@pytest.mark.asyncio
async def test_concurrent_write_and_compact_consistency(store: MemoryStoreProtocol):
user = "user_42"
# 并发写入 10 条消息
async def writer(start_idx: int):
for i in range(start_idx, start_idx + 5):
await store.append_message(user, "user", f"msg{i}")
# 两个写协程同时进行
await asyncio.gather(writer(0), writer(5))
# 在写入未完全落盘时触发压缩
await asyncio.sleep(0.01) # 模拟真实调度间隙
await store.compact(user)
# 用 tenacity 轮询一致状态,最多等 2 秒
@retry(stop=stop_after_delay(2), wait=wait_fixed(0.1), reraise=True)
async def assert_eventually_consistent():
ok = await store.consistent_state(user)
if not ok:
raise AssertionError("State inconsistent: compact left raw data behind")
await assert_eventually_consistent()
你猜怎么着?这个测试必然失败 ,因为 compact 故意没删 raw_pg,consistent_state 返回 False,两秒后 AssertionError 抛出。这正是我们要抓的 bug。
4. 用参数化覆盖多种异常时序
问题往往不是单次并发,而是指令序列。我们通过 pytest.mark.parametrize 把"先写后压"、"边写边压"、"只写不压"等场景排列组合,一网打尽。
python
@pytest.mark.asyncio
@pytest.mark.parametrize("write_count, compact_after, read_during", [
(1, True, False), # 单条写入后立即压缩
(10, True, True), # 大量写入 + 并发读 + 压缩
(100, False, True), # 大量写入,不压缩,验证读取正常
])
async def test_consistency_scenarios(store, write_count, compact_after, read_during):
user = "param_user"
# 填充消息
for i in range(write_count):
await store.append_message(user, "user", f"p{i}")
if read_during:
# 并发读,不能阻塞写
async def reader():
for _ in range(5):
hist = await store.get_history(user)
assert isinstance(hist, list)
asyncio.create_task(reader())
if compact_after:
await asyncio.sleep(0.02) # 等待后台写入完成一部分
await store.compact(user)
# 最终一致性检查
@retry(stop=stop_after_delay(3), wait=wait_fixed(0.1))
async def check():
assert await store.consistent_state(user)
await check()
踩坑记录
坑 1:pytest-asyncio 事件循环与 create_task 的僵尸任务
- 现象:测试明明跑完了,终端卡住 30 秒才退出,提示 "Task was destroyed but it is pending!"。
- 原因:我在测试里用了
asyncio.create_task(reader())但没有 await 那个 task,pytest-asyncio 默认的事件循环在所有测试用例结束后才关闭,那些后台读任务的协程就变成了悬空任务。 - 解决:用
asyncio.wait_for明确等待后台任务完成,或使用pytest-asyncio的event_loopfixture 设为function作用域,并确保每个 task 都有明确的取消逻辑。教训:测试里的异步任务必须显式管理生命周期,别偷懒。
坑 2:pytest.fixture 作用域弄错导致存储实例污染
- 现象:加了第二个测试用例后,第一个用例中残留的数据导致第二个用例的
consistent_state直接通过,也就是假阳性。 - 原因:我贪图省事把
storefixture 设成了scope="session",想复用连接。结果所有测试共享同一个存储实例,状态互相影响。 - 解决:改成
scope="function"(默认值),每个测试完全隔离。如果怕创建开销大,可以在 fixture 里用await store.clear_all()做一次清理,而不是提升作用域。性能不够靠测试分层来解决,永远别在状态性测试中共享实例。
效果验证
| 指标 | 手动测试 | Pytest 自动化 | 变化 |
|---|---|---|---|
| 状态一致性缺陷发现率 | 肉眼抽样 20% | 参数化覆盖 100% 场景 | 提升 5 倍 |
| 回归测试耗时 | 人工跑 15 分钟 | CI 并行 42 秒 | 减少 95% |
| "压缩后残留"类逃逸 bug 数量(季度) | 3 个 / 季度 | 0 个 | 消除 |
部署之后的第一个月,线上再没收到过" AI 失忆"的投诉。测试套件到现在已经累积了 43 个场景,每次有人改记忆压缩逻辑,CI 用 42 秒就把所有可能的状态不一致亮红灯。这种感觉,比凌晨两点被叫醒修 bug 踏实多了。
可直接用的代码/工具
把下面这段加到 conftest.py,配合 pytest-asyncio 和 tenacity,就能跑起上面的测试:
python
# conftest.py
import pytest
from store import FragileMemoryStore
@pytest.fixture
async def store():
s = FragileMemoryStore()
yield s
# 极端情况下手动清理,确保隔离
s.short_term.clear()
s.vector.clear()
s.raw_pg.clear()
#Python #Pytest #大模型 #状态一致性 #后端测试
关于作者
一个习惯在深夜写测试而不是修 bug 的后端 / 架构工程师,专注把分布式系统那些"偶尔出现"的故障变成 CI 的红灯。
GitHub: github.com/baofugege (后续会把本文完整示例仓库公开)
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你揪出了一个隐藏的竞态,请我喝杯咖啡
提供服务:Python 后端性能优化 / 架构设计 / 自动化测试体系搭建,联系 Telegram @baofugege