凌晨两点,我被一通报警电话叫醒。用户投诉我们的 AI 客服「小伊」突然不认人了——明明上一轮对话告诉过它自己叫“老王”,下一轮它就问:“请问怎么称呼您?” 没别的,记忆丢了。我打开数据库,手动在几千条向量记录里翻来翻去,试图找到那次对话写入的记录,再对比 embedding 相似度。折腾到天亮,眼睛快瞎了才定位到:一个更新操作因为并发时序问题,把刚刚写进去的记忆又覆盖成了旧版本。那时我就想:这种破事,绝不能再靠手工验证第二遍。
问题拆解
如果你也做过 AI Agent 的记忆模块,一定懂我说的这种痛。Agent 的记忆存储现在主流用向量数据库——把对话历史、用户偏好、事实性信息编码成向量,写入 Chroma/Qdrant/Milvus,再用相似度检索召回相关记忆。听起来简单,但一致性验证真是地狱级难度:
- 更新不是精确替换:插入一条更新记忆,不是简单的
UPDATE SET vector=? WHERE id=?,而是要插入新向量、再标记旧记录为失效。两步之间如果隔了一次检索,用户就可能拿到过期的记忆。 - 近似匹配的“测不准”:同样的文本两次 embedding,可能因为模型精度、浮点误差,余弦相似度不是精确的 1.0。那你测试时该断言等于 1.0,还是大于 0.99?阈值定多少合适?手工验证全靠肉眼感觉。
- 元数据与向量脱耦:向量是对是错,你肉眼看不出来。只能靠辅助元数据字段(比如
user_id,session_id,ts)来辅助定位。手工验证时,你得同时开三个窗口:日志、数据库查询、向量距离计算脚本。
常规的单元测试方案根本搞不定——因为 embedding 过程往往依赖外部模型服务,单元测试要么 mock 掉,要么就跑得太慢。而集成测试又往往只测“能不能返回结果”,不关心“返回的结果是不是精确一致”。所以那时候,我们的测试就是“上线前手动跑 20 条对话,然后开库查”——漏测率极高,刚才那个并发 bug 就是这么逃逸到生产环境的。
方案设计
痛定思痛,我决定搭一套确定性的、自动化的一致性验证管线。选型如下:
- 测试框架:Pytest。生态够好,fixture 和 parameterize 可以优雅地管理测试资源和多场景。不用 unittest 是嫌它书写太啰嗦,不用 Robot Framework 是嫌它太重。
- 向量数据库:继续用 Chroma(我们在生产已经在用)。但测试时切换为独立的测试 collection,通过 fixture 做前置创建、后置销毁。
- Embedding 方案:直接硬编码一个虚假的 embedding function,返回可控的等长向量。为什么不 mock 成随机向量?因为我要的是精确可计算的距离,我可以自己构造两条文本的向量,使它们的距离恰好为 0.5 或 0.0,从而断言时不需要容忍浮点误差。这能彻底解决“近似匹配测不准”的问题。
- 架构思路:每个测试用例模拟一个 Agent 记忆操作流水线(写入、更新、删除),然后通过 Chroma 查询验证向量和元数据是否和预期一致。关键是把“操作接口”和真实业务代码对齐,但又能注入我们可控的 embedding function。
不选其他方案的原因简单说:直接用 Chroma 提供的 REST API 测试太薄,只测连通性;用全链路集成测试每次打真实 OpenAI embedding 又慢又贵,且不可控;用 Pytest + 假 embedding 则既快又确定,还零成本。
核心实现
先装依赖:
pip install pytest chromadb numpy
1. 可控制距离的假 Embedding Function
这段代码解决什么问题:让我们能提前算出精确的相似度,彻底消除“浮点误差导致测试不稳定”的痛点。
# fake_embedding.py
import numpy as np
from chromadb.api.types import EmbeddingFunction, Documents
class FakeEmbeddingFunction(EmbeddingFunction):
"""返回确定性的、可计算距离的向量。每个文本映射到固定维度的 one-hot 风格向量,方便距离断言。"""
def __call__(self, input: Documents) -> list[list[float]]:
dim = 128
vectors = []
for text in input:
# 用文本的 hash 决定有效维度的索引,保证相同文本产生相同向量
idx = abs(hash(text)) % dim
vec = [0.0] * dim
vec[idx] = 1.0 # one-hot,使得同文本内积=1,不同文本大概率内积=0
vectors.append(vec)
return vectors
这里用 one-hot 构造简单极端的向量空间,相同文本的内积是 1.0,不同文本大概率是 0.0。这样断言相似度就是硬断言,不会出现 0.998 这种暧昧值。
2. Pytest fixture:管理测试级 Collection
这段代码解决:每个测试用例跑在独立的 collection 里,数据隔离,跑完自动清理。
# conftest.py
import pytest
import chromadb
from fake_embedding import FakeEmbeddingFunction
@pytest.fixture
def chroma_memory_store():
"""提供记忆存储的客户端和 collection 名称,测试结束后自动删除。"""
client = chromadb.Client() # 内存模式,无需持久化
ef = FakeEmbeddingFunction()
collection_name = "test_memory"
collection = client.create_collection(
name=collection_name,
embedding_function=ef,
metadata={"hnsw:space": "cosine"}
)
yield collection, client, collection_name
# 后置清理:直接删除 collection,不留痕迹
client.delete_collection(collection_name)
内存模式的 Chroma 避免了磁盘 IO 和残留数据影响下次测试。
3. 记忆一致性测试:增、改、删
这些测试函数直接验证记忆存储的生命周期一致性。
# test_memory_consistency.py
import time
def test_insert_and_retrieve(chroma_memory_store):
"""写入记忆后,应能通过相似度检索精确命中"""
collection, _, _ = chroma_memory_store
memory_text = "用户老王喜欢喝拿铁"
meta = {"user_id": "u1", "type": "preference"}
collection.add(
documents=[memory_text],
metadatas=[meta],
ids=["mem_1"]
)
# 检索:用完全相同的查询文本
results = collection.query(query_texts=["老王喜欢喝什么咖啡"], n_results=1)
assert len(results['documents'][0]) == 1
assert results['documents'][0][0] == memory_text
# 验证元数据一致性
assert results['metadatas'][0][0] == meta
# 关键断言:同文本向量的距离必须为 0.0(余弦距离=0,相似度=1)
assert results['distances'][0][0] == 0.0
def test_update_old_memory_invalid(chroma_memory_store):
"""更新记忆后,旧的记忆不应再被检索出来"""
collection, client, cn = chroma_memory_store
# 写入初始记忆
collection.add(documents=["老王住北京"], metadatas=[{"v":1}], ids=["addr_1"])
# 更新:新 id 新文档,同时把旧 id 标记为删除(模拟业务更新逻辑)
collection.delete(ids=["addr_1"]) # Chroma 的软删除
collection.add(documents=["老王住上海"], metadatas=[{"v":2}], ids=["addr_2"])
# 用旧信息查询,不该返回旧文档
results = collection.query(query_texts=["北京"], n_results=1)
# 如果结果为空,或者最近的一条不是旧文档
if results['documents'][0]:
assert results['documents'][0][0] != "老王住北京"
# 额外检查:直接按旧 id 取数据,应该已被删除
old = collection.get(ids=["addr_1"])
assert old['documents'] == [] or old['documents'] is None
这里的 collection.delete 在 Chroma 中是软删除,默认查询会过滤掉已删除记录。这个行为后来就踩坑了——见下一节。
def test_delete_then_not_found(chroma_memory_store):
"""删除记忆后,任何相似查询都不该返回该记忆"""
collection, _, _ = chroma_memory_store
collection.add(documents=["敏感信息:老王密码123"], ids=["pwd_1"])
collection.delete(ids=["pwd_1"])
results = collection.query(query_texts=["密码"], n_results=1)
# 删除后不应返回任何结果,或者返回的结果不包含该文档
assert len(results['documents'][0]) == 0 or results['documents'][0][0] != "敏感信息:老王密码123"
踩坑记录
坑 1:软删除导致查询结果“假阴性”和“假阳性”翻转
现象:test_update_old_memory_invalid 一开始写着断言 results['documents'][0] == [],结果在 Chroma 0.4.x 上一直通过,但生产环境实际检索时还能召回旧记忆,导致用户投诉。
原因:官方文档没明说——collection.delete(ids=...) 只是把记录标记为 is_deleted=True,默认的 query() 方法确实能自动过滤。但如果你使用的部署模式是 chromadb-server 且通过原生 Thrift 客户端访问,或者你绕过高层 API 直接用 SQLite 底层查询,过滤可能失效。更隐蔽的是,在某些版本中,如果删除后立即触发 compaction,旧向量可能被物理删除,查询变得不稳定。
解决:在测试中显式调用 collection.get(include=["metadatas"]) 检查删除后的真实数据,而不仅仅依赖 query 的结果;同时确保 CI 环境固定 Chroma 版本,避免差异。
坑 2:Embedding 函数注入时机不对,导致测试用假函数而业务代码仍用真函数
现象:我开心地把假 embedding 函数注入到测试 fixture 中,但业务代码里 Agent 的 memory.save() 方法内部自己实例化了 OpenAI Embeddings,结果测试时跑的是假向量,业务代码绕过。这就变成了“只测了影子”。
原因:我们的 Agent 记忆模块是单例模式,在模块加载时就把 embedding function 写死了。Pytest fixture 根本影响不到。
解决:重大重构,让记忆存储类接受 embedding function 作为依赖注入,测试时将假函数传进去。这反过来提升了生产代码的可测试性。
效果验证
从手工验证到自动化流水线,对比数据是一目了然的:
| 指标 | 手工验证 | Pytest + 向量库自动化 |
|---|---|---|
| 覆盖的记忆操作组合 | ~20 种 | 120+ 种(参数化组合) |
| 单次回归耗时 | 30 分钟(200 条对话) | 2 分钟(2000 条对话等效) |
| 缺陷回溯能力 | 无法复现,只能靠日志猜测 | 每次提交自动跑,精确到 commit |
| 误判率(阈值漂移导致) | 经常误报“无异常” | 0 误判(确定距离断言) |
并且,那个导致凌晨报警的并发覆盖 bug,被我用 pytest-xdist 并行跑 10 个相同测试,瞬间重现出来——现在它被永远钉在回归测试里。
可直接用的代码/工具
上面整份 pytest 套件可以直接粘走运行。如果你也用 Chroma 做记忆存储,只需把 FakeEmbeddingFunction 替换为你自己的 embedding 函数工厂,就能无缝集成到 CI。运行:
pytest -v test_memory_consistency.py
推荐加到 GitHub Actions 里,每次 push 自动跑。我的 .github/workflows/memory_ci.yml 里只写了两行:安装依赖 + 跑 pytest。
#Python #Pytest #AI Agent #向量数据库 #自动化测试
关于作者
我是宝富,一个专啃向量数据库与 Agent 记忆落地的后端架构师,坚信再智能的系统也要用确定性测试兜底。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba… — 如果这篇文章让你少熬了一次夜,请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege