凌晨两点,用户反馈 AI Agent 突然忘了昨天聊过的项目细节,我睡眼惺忪地打开 Grafana,看到记忆召回率从 98% 掉到 60%。第一反应是 embedding 模型又抽风了,结果查了半天日志,发现是 Qdrant 的写入和查询之间有个"时间差"------数据明明 upsert 了,查询却时有时无。这不是第一次了,我决定用 pytest + Qdrant 把召回一致性测试自动化,结果发现坑比想象中深。
问题拆解
AI Agent 的记忆存储用 Qdrant 存向量和 payload,核心需求是「写入后立刻可召回」。但在真实场景中,召回不一致的表现很诡异:本地测试全过,CI 上偶尔挂;同一个查询,连续执行两次结果不一样。根因在于 Qdrant 的写入和索引构建是异步的------upsert 方法默认不等待索引刷新就返回,导致后续查询可能读到空结果或部分结果。常规方案是手动在代码里加 time.sleep(2),但这不是工程做法,而且 sleep 时间不固定,治标不治本。更麻烦的是,召回一致性还涉及距离度量和 score 阈值的选择,一个不小心测试断言就会误导你。
方案设计
我选 pytest 作为测试框架,因为它的 fixture 和参数化能力非常适合做隔离和覆盖多场景测试。Qdrant 官方 Python 客户端支持 :memory: 模式,每个测试用例可以拿到一个干净的向量数据库实例,完全隔离,不用 Mock------Mock 测不出真实索引行为。为什么不选 unittest?因为 fixture 清理和参数化写起来太啰嗦。为什么不选 mock?因为我们要验证的是真实 Qdrant 的召回行为,mock 只会掩盖异步索引问题。
架构上,我在 conftest.py 里定义一个 qdrant_client fixture,每个测试自动创建独立 collection,测试结束自动清理。核心测试覆盖三类场景:单点召回、批量写入后召回、带 payload filter 的召回。
核心实现
这段代码解决测试环境隔离问题,确保每个测试用独立的 Qdrant 实例和 collection。
python
# conftest.py
import pytest
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance
@pytest.fixture
def qdrant_client():
# 内存模式:每个测试完全隔离,不落盘,速度快
client = QdrantClient(":memory:")
yield client
client.close()
@pytest.fixture
def mem_collection(qdrant_client):
collection_name = "agent_memory_test"
# 向量维度 3,余弦距离
qdrant_client.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(size=3, distance=Distance.COSINE)
)
return qdrant_client, collection_name
这段代码暴露第一个坑:直接 upsert 后立即查询,召回结果不稳定。
python
# test_recall_failure.py
import pytest
from qdrant_client.models import PointStruct
def test_immediate_recall_without_wait(mem_collection):
client, collection = mem_collection
# 写入一条向量,注意:没有 wait=True
client.upsert(
collection_name=collection,
points=[PointStruct(id=1, vector=[0.1, 0.2, 0.3], payload={"user": "alice"})]
)
# 立即查询相同向量,期望能召回
result = client.query_points(
collection_name=collection,
query=[0.1, 0.2, 0.3],
limit=1
)
# 这一行经常失败:result.points 为空
assert len(result.points) == 1
运行这个测试,10 次有 6 次挂掉,报 AssertionError: assert 0 == 1。这就是线上召回率抖动的原因。
这段代码修复索引刷新问题:wait=True 强制等待索引就绪后再查询。
python
# test_recall_fix.py
import time
from qdrant_client.models import PointStruct, VectorParams, Distance
def test_immediate_recall_with_wait(mem_collection):
client, collection = mem_collection
# 关键参数:wait=True,确保写入后索引刷新完成
client.upsert(
collection_name=collection,
points=[PointStruct(id=1, vector=[0.1, 0.2, 0.3], payload={"user": "alice"})],
wait=True # 官方文档没明说,但这是解决异步索引的关键
)
# 现在查询稳定返回结果
result = client.query_points(
collection_name=collection,
query=[0.1, 0.2, 0.3],
limit=1
)
assert len(result.points) == 1
assert result.points[0].id == 1
# 还可以进一步验证 payload 一致性
assert result.points[0].payload["user"] == "alice"
如果不想每个调用都加 wait=True,可以封装一个 safe_upsert 函数,内部轮询 collection 状态直到 optimizers_status 变为 ok。
踩坑记录
坑 1:upsert 后立即查询返回空结果,CI 上随机挂掉。 现象:本地测试通过,推到 CI 就挂,错误信息 assert 0 == 1。原因:Qdrant 的 upsert 方法默认不等待索引刷新,数据虽然写入存储,但 HNSW 索引还没构建好,查询走了索引,结果为空。解决:使用 wait=True 参数,或者轮询 client.get_collection(collection).status 直到 optimizers_status 为 ok。官方文档对 wait 参数的说明在 API 参考里,很容易被忽略。
坑 2:用 score_threshold 过滤时,阈值设置错误导致测试误判。 现象:测试断言 result.points[0].score > 0.7 一直失败,即使返回了正确向量。原因:Qdrant 使用余弦距离时返回的 score 是余弦相似度,范围是 -1, 1,不是距离,所以越小越不相似。我在另一个测试里用了欧氏距离的阈值 0.5,结果全部不通过。解决:明确距离度量对应的 score 语义------余弦相似度越大越好,欧氏距离越小越好;测试断言要和实际语义一致,不要混用。
效果验证
修复前,召回一致性测试套件在 CI 上 10 次运行 6 次失败,平均排查时间 20 分钟/次;修复后,100 次运行全部通过,回归测试从手动 10 分钟缩短到自动化 8 秒。
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 测试通过率 | 40% | 100% |
| 单次回归耗时 | 10 分钟(人工) | 8 秒(自动化) |
| 线上召回率抖动 | 频繁 | 无 |
可直接用的代码/工具
把下面这个 safe_upsert 函数复制到你的 Qdrant 工具类里,所有写入走这个函数,告别召回不一致:
python
def safe_upsert(client, collection, points):
client.upsert(collection_name=collection, points=points, wait=True)
# 双保险:轮询索引状态
while True:
status = client.get_collection(collection).status
if status.optimizers_status == "ok":
break
time.sleep(0.1)
#Python #后端 #向量数据库 #AI工程 #测试自动化
关于作者
一个实战派后端/架构方向的开发者,专注 AI Agent 基础设施和向量数据库落地。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮到你,请我喝杯咖啡
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege