凌晨两点,测试同事在群里疯狂@我:"对话机器人的长期记忆完全乱套了,用户明明聊过'我家猫叫豆包',结果隔了几天再去问,它回顾成'你家狗叫雪饼'。更诡异的是,分页翻到第3页又出现了第1页的内容。现在线上2000多个用户的记忆都在错位,你快看看。"
我睡眼惺忪地打开监控,发现存储向量和元数据的那几张表,写入QPS正常,API返回200,没有任何报错。那一刻我就知道------不是简单的代码bug,而是没有做一致性回归测试,我们对"记忆存储"这个黑盒的信任,是从上线第一天就在裸奔。
问题拆解
大模型长记忆存储(比如Mem0、LangChain的记忆模块、自研的向量索引服务)通常依赖一个"记忆流水":
- 用户与模型对话产生事实(memory fact)。
- 事实经过embedding后存入向量库(pgvector、Milvus等),同时保留时间戳、用户ID、会话ID等结构化字段。
- 后续对话根据相似度+时间衰减召回topN记忆,进行分段分页拼接。
出问题的就是第3步:为了保证每次检索结果可复现、分页连贯,必须同时满足排序确定性 + 分页不重不漏 。但实际环境中,很多人直接把ORDER BY created_at配上LIMIT/OFFSET就上线了,并发写入时立刻翻车------因为同一时间戳下插入顺序不确定,OFFSET会跳过或重复记录。另外,部分服务为了性能还会缓存元数据,一旦缓存更新不及时,就会出现"刚插入的记忆在查询时蒸发"的幽灵现象。
常规的后端CRUD测试手段在这里完全失效:你没法用几个手工编造的时间戳模拟生产环境中2000条记忆的并发写入、分页漂移、缓存不一致等场景。必须搞一套自动化一致性测试框架,像揉面一样反复蹂躏你的记忆系统,直到它服软为止。
方案设计
目标是构建一个可对任意长记忆存储后端(Postgres、Milvus、甚至内存KV)运行的一致性测试套件。核心思路:生成海量符合真实分布的记忆 → 批量并发插入 → 用不同分页/排序策略拉取 → 校验数学恒等式。
技术选型:Python + pytest,记忆生成用Faker,存储抽象成一套interface。为什么不直接用存储自带的SQL脚本来校验? 因为我们要测的是最终给到大模型的"记忆视图",即API返回的分页结果,而不是底层数据库的物理行数。直接查库等于测错了对象。为什么不选Go/JMeter做压测? 我们需要的是逻辑断言(如所有分页结果合并后集合相等),不是吞吐量基准,pytest的参数化夹具和allure报告在这类场景下开发效率高出至少一个数量级。
架构非常简单:定义一个MemoryStore协议,包含insert(memories)和recall(user_id, page, size, sort_order)两个方法;然后围绕它编写一套pytest测试用例,再通过依赖注入适配到任何具体的数据库实现。这样哪怕你从Postgres换成自研的向量引擎,测试套件也一行不用改。
核心实现
这段代码解决什么问题?
先定义记忆数据模型和存储抽象接口,让测试逻辑与具体后端解耦。这样你换数据库时,只需要实现两个方法,整个测试套件立即可跑。
python
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Protocol, List, Optional
from uuid import uuid4
@dataclass
class Memory:
"""单条记忆实体"""
memory_id: str = field(default_factory=lambda: uuid4().hex)
user_id: str = ""
content: str = ""
created_at: datetime = field(
default_factory=lambda: datetime.now(timezone.utc)
) # 重点:必须带UTC时区,否则排序会踩坑
class MemoryStore(Protocol):
"""记忆存储协议,所有后端实现此接口"""
async def insert_batch(self, memories: List[Memory]) -> None:
...
async def recall_page(
self,
user_id: str,
page: int,
page_size: int,
sort_order: str = "desc"
) -> List[Memory]:
...
接下来是分页一致性检验的核心测试函数------它会自动生成一大波记忆,搅进存储中,然后逐页拉取,验证数学恒等式:
这段代码解决什么问题?
模拟真实用户写入2000条记忆后,以不同分页大小遍历所有页,检查无重复、无遗漏、排序严格一致。任何一点不一致立刻终止并给出可复现的失败信息。
python
import pytest
from faker import Faker
from collections import OrderedDict
fake = Faker()
async def run_pagination_consistency(
store: MemoryStore,
user_id: str,
total: int,
page_size: int
):
# 1. 插入 total 条记忆,时间故意打乱以模拟并发
memories = [
Memory(
user_id=user_id,
content=fake.sentence(),
# 让部分时间戳非常接近,触发排序不稳定
created_at=datetime(
2025, 5, 1, 12, 0, tzinfo=timezone.utc
) + timedelta(seconds=i * 0.01)
)
for i in range(total)
]
# 随机重排插入顺序,模拟并发写入乱序
random.shuffle(memories)
await store.insert_batch(memories)
# 2. 预期全集:按created_at降序(最新在前)且相同时间戳用memory_id兜底
expected_ids = [
m.memory_id for m in sorted(
memories,
key=lambda m: (m.created_at, m.memory_id),
reverse=True
)
]
# 3. 逐页抓取,并记录已见过的ID,检测重复
retrieved_ids = []
page = 1
while True:
page_memories = await store.recall_page(
user_id, page, page_size, sort_order="desc"
)
if not page_memories:
break
for m in page_memories:
assert m.memory_id not in retrieved_ids, \
f"重复记忆 {m.memory_id} 出现在第 {page} 页"
retrieved_ids.append(m.memory_id)
page += 1
# 4. 验证无遗漏且顺序正确
assert retrieved_ids == expected_ids, \
f"不一致:检索到 {len(retrieved_ids)} 条,预期 {len(expected_ids)} 条"
print(f"分页一致性通过:{total}条记忆, 页大小{page_size}")
这段代码解决什么问题?
用pytest参数化针对不同页大小、不同记忆总数跑上述逻辑,一次性覆盖边界(比如单页跨所有数据、最后页不满等)。
python
@pytest.mark.asyncio
@pytest.mark.parametrize("total", [10, 200, 2000])
@pytest.mark.parametrize("page_size", [1, 7, 50, 100])
async def test_memory_consistency(store: MemoryStore, total, page_size):
user_id = f"user_{uuid4().hex[:8]}"
await run_pagination_consistency(store, user_id, total, page_size)
就这样,100多行代码覆盖了你手工测试三天都无法穷举的组合。任何存储实现,只要接入MemoryStore,一把梭全过才敢上线。
踩坑记录
坑1:LIMIT/OFFSET分页像抽盲盒
现象 :2000条记忆,页大小50,翻到第10页返回42条(明明总数整除页大小后应有40页),而且第11页开头重复了上页末尾的3条。
原因 :并发插入时,created_at微秒精度不足,导致多条记录时间戳完全一致。ORDER BY created_at DESC LIMIT 50 OFFSET 200 每次执行会得到不同的第201~250条------Postgres不保证相同排序键下的稳定顺序。
解决 :所有分页查询改为游标分页 (keyset pagination),用上一次返回的最后一条记录的(created_at, memory_id)作为WHERE条件,彻底消除偏移量漂移。对外API接口需强制要求after_timestamp和after_id参数。这在官方文档里很少提到,但却是生产事故重灾区。
坑2:datetime没有时区,排序像开盲盒2.0
现象 :测试环境一切正常,上线后记忆排序偶尔错乱。
原因 :测试机器使用UTC,插库时datetime.now()不带时区信息;生产环境的Python镜像时区设置为Asia/Shanghai,同样不带时区,但Django/ORM层写入时自动转换成字符串,导致排序既不是本地时间也不是UTC。
解决 :强制所有记忆时间戳使用datetime.now(timezone.utc),存储层面统一采用带时区的timestamptz类型。如果底层是JSON字段,也必须存ISO8601加Z后缀。这是框架接入文档上没说的------他们假设你"有这个常识",但我踩过的坑告诉我,10个团队里有8个会掉进去。
效果验证
| 指标 | 手工测试(之前) | 自动化一致性框架(之后) |
|---|---|---|
| 覆盖2000条记忆所需时间 | 3小时以上(拼手速) | 28秒(pytest -n32并行) |
| 发现过的诡异顺序bug | 0个(线上发现) | 5个(均在PR阶段拦截) |
| 新后端接入成本 | 2天编写一次性脚本 | 1小时实现Protocol |
最爽的是,后面从pgvector迁移到Milvus时,我直接把旧service改了4行代码实现MemoryStore接口,跑一遍测试套件就敢灰度上线。那种把安全感锁死的感觉,比任何监控报警都踏实。
可直接用的代码/工具
把上述MemoryStore协议和run_pagination_consistency函数拷到一个test_helpers.py里,你的存储后端只要实现两个方法,然后运行:
bash
pytest -v -n auto test_memory_consistency.py
就能立刻获得2000条记忆分页一致性测试。如果你用Mem0或自研向量记忆,只需要用一个薄适配层包装它的add()和search()。
#Python #LLM #记忆系统 #自动化测试 #踩坑
关于作者
我是宝斧,一个始终泡在LLM应用落地和后端架构里的实战派开发者。写代码喜欢"测试先行",尤其擅长把折腾过你的坑变成所有人不再踩的路。
GitHub: github.com/baofugege --- 本文涉及的自定义测试框架源码后续也会整理放上去。
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你省了几小时的排查时间,欢迎请我喝杯咖啡。
提供服务:Python后端性能优化 / 工具定制 / 技术咨询,可联系 Telegram @baofugege。