用 Pytest 接管大模型记忆存储测试,状态一致性问题减少 90%

凌晨两点十七分,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) 这种拍脑袋的等待,而是用 tenacityretryasyncio.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_pgconsistent_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-asyncioevent_loop fixture 设为 function 作用域,并确保每个 task 都有明确的取消逻辑。教训:测试里的异步任务必须显式管理生命周期,别偷懒。

坑 2:pytest.fixture 作用域弄错导致存储实例污染

  • 现象:加了第二个测试用例后,第一个用例中残留的数据导致第二个用例的 consistent_state 直接通过,也就是假阳性。
  • 原因:我贪图省事把 store fixture 设成了 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-asynciotenacity,就能跑起上面的测试:

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

相关推荐
IT_陈寒5 小时前
JavaScript的异步地狱里,我的Promise掉进了微任务陷阱
前端·人工智能·后端
嘟嘟07175 小时前
大前端工程师必学:用 BFF 架构搭建安全的 AI 流式应用
前端
meilindehuzi_a6 小时前
Vue 项目中的前端 BFF:跨域代理与流式请求实战
前端·javascript·vue.js
小则又沐风a6 小时前
库的原理:目标文件,ELF格式,程序加载
java·linux·前端
程序员爱钓鱼7 小时前
Rust if let 与 while let 详解:简化模式匹配
前端·后端·rust
胡萝卜术7 小时前
从暴力到滑动窗口的终极形态:力扣3「无重复字符的最长子串」的优化进化之路
前端·javascript·面试
钛态15 小时前
前端安全防线:CSRF 攻击链路与双重 Token 校验的工程实现
前端·vue·react·web
张拭心16 小时前
技术管理笔记:让我“燃尽”的一天
前端
不好听61317 小时前
BFF 架构实战:从前端直调 API 到加入中间层
前端·架构