凌晨 2:17,老板截图扔到群里:我们的 AI 助理把上周跟 A 客户的报价方案,原封不动发给了 B 客户。打开 LangSmith 一查 trace,向量检索召回了不该出现的记忆片段,相似度 0.83,刚好过阈值------但它本该是另一个 session 的。连夜修完 bug,我盯着屏幕上那串 vectorstore.similarity_search(score_threshold=0.8),第一次对自己的记忆层失去了信任。第二天我就拍板:给记忆存储加全套自动化测试,不是可选的,是必须的。 下面就是我在这个过程中踩过的三个大坑,以及一套你可以直接复用的集成测试方案。
问题拆解:为什么 LLM 记忆层这么难测?
常规后端经验里,测试存储层无非是 CRUD 断言。但 LLM 的记忆存储有三个"反直觉"的困难点:
- 非确定性 :向量检索返回的文档顺序可能因浮点运算抖动而变化,
similarity_search算出的分数在你本地和 CI 的 CPU 架构上都能差出 0.01。 - 隐式副作用 :LangChain 的
ConversationBufferMemory或VectorStoreRetrieverMemory会往 context 里塞隐藏键。你查文档看到memory.chat_memory.messages,以为清空了;但底层向量库里的 embedding 压根没删,下一次检索又冒出来。 - 并发写放大 :多个用户线程同时写记忆时,如果底层连接池配置不当或没有 use
upsert,轻则写入性能雪崩,重则直接丢消息。
常规方案 ------ 单测里 mock 掉 vectorstore 或者只测 buffer 层 ------ 完全抓不住这些问题。真正的安全感,必须来自启动真实向量数据库、运行多轮并发交互的集成测试。
方案设计:带上真实依赖,但用确定性数据
选型:pytest + pytest-asyncio + Docker 启动的 Chroma(或 pgvector)。没选 FAISS 是因为它会在本地生成二进制文件,跨环境一致性差;没选纯 mock,因为要暴露连接池泄露、序列化 bug 这些"真刀真枪"的问题。
架构思路很简单:
- 用
docker-compose或testcontainers在 pytest session 级别起一次 Chroma 容器,所有测试共享同一个 vectorstore 但用隔离的 collection。 - 所有测试不用 LLM 生成真实 embedding,而是用
FakeEmbeddings(固定向量)或直接插入预设 embedding 数组,保证检索结果可精确断言。 - 围绕记忆层的三个核心风险点设计测试用例:正确性 (增删改查后召回一致)、并发安全 (并发写入无丢失、无覆盖)、性能底线(P99 延迟、连接池健康)。
核心实现:三个测试让你睡安稳觉
1. 记忆存储一致性测试------解决"串台"隐患
这段代码验证:写入一批消息到指定 session 后,其他 session 绝对检索不到;且本 session 能按预期召回所有消息。我们用 FakeEmbeddings 将文本映射到固定维度向量,使相似度完全可预测。
python
# test_memory_consistency.py
import asyncio
import pytest
from langchain.vectorstores import Chroma
from langchain.embeddings import FakeEmbeddings
# 固定向量长度,确保测试可重复
FAKE_EMBED_SIZE = 384
@pytest.fixture(scope="session")
def vectorstore():
""" 启动 Chroma 客户端,每个测试使用独立 collection 避免污染 """
embeddings = FakeEmbeddings(size=FAKE_EMBED_SIZE)
vs = Chroma(
collection_name="test_memory",
embedding_function=embeddings,
persist_directory="/tmp/test_chroma" # 可挂载到容器内路径
)
yield vs
# 测试结束后彻底清空,防止残留
vs.delete_collection()
def test_cross_session_isolation(vectorstore):
""" 不同 session 的记忆必须严格隔离 """
session_a, session_b = "user_123", "user_456"
# 写入 session_a 的消息
vectorstore.add_texts(
texts=["我叫张三,预算50万"],
metadatas=[{"session_id": session_a}]
)
# 检索 session_b 的记忆------不应有结果
results_b = vectorstore.similarity_search(
"预算", k=3,
filter={"session_id": session_b}
)
assert len(results_b) == 0, "session B 不应该召回 A 的记忆"
# session_a 自己应该能召回
results_a = vectorstore.similarity_search(
"预算", k=3,
filter={"session_id": session_a}
)
assert any("50万" in doc.page_content for doc in results_a)
2. 并发写入安全性测试------避免"丢失更新"
使用 asyncio.gather 模拟多个协程同时向同一 session 写入记忆,最后断言该 session 下的文档总量等于并发数。这里暴露了一个极易触发的坑:LangChain 的 add_texts 底层调用 collection.add,如果不带唯一 ID,并发请求可能各自插入全量 embedding,导致数据重复或 OOM。
python
# test_concurrent_writes.py
import asyncio
import pytest
from langchain.vectorstores import Chroma
from langchain.embeddings import FakeEmbeddings
@pytest.fixture(scope="module")
def vectorstore():
embeddings = FakeEmbeddings(size=384)
vs = Chroma(
collection_name="test_concurrent",
embedding_function=embeddings,
persist_directory="/tmp/test_chroma"
)
yield vs
vs.delete_collection()
async def add_memory(vs, content, session):
""" 封装写入操作,注意要 await 可能存在的异步特性 """
# Chroma 的 add_texts 是同步方法,但真实环境可能切到 async client,
# 这里为了演示并发,用 run_in_executor 模拟线程并发写入
await asyncio.to_thread(
vs.add_texts,
texts=[content],
metadatas=[{"session_id": session}]
)
@pytest.mark.asyncio
async def test_concurrent_writes_no_loss(vectorstore):
session_id = "heavy_user"
concurrency = 20
tasks = [
add_memory(vectorstore, f"记忆片段 {i}", session_id)
for i in range(concurrency)
]
await asyncio.gather(*tasks)
# 检查写入数量是否完整
results = vectorstore.similarity_search(
"记忆片段", k=100,
filter={"session_id": session_id}
)
# 可能存在相似度过滤导致的漏召,这里用数量断言
assert len(results) >= concurrency * 0.95, \
f"并发写入后预期至少 {concurrency} 条,实际召回 {len(results)} 条,可能丢失"
这段代码实际跑的时候,我第一次就栽了 ------asyncio.to_thread 线程池默认大小有限,并发一大就阻塞。后来直接重构为使用 chromadb 原生 async client,这才是后话。
3. 性能基线测试------连接池健康度监控
我们不光要看功能对不对,还得保证 CI 里每次提交都不会让写入延迟翻倍。这个用例用 time.perf_counter 测量批量写入延迟,并断言连接池未耗尽。
python
# test_performance.py
import time
import pytest
from langchain.vectorstores import Chroma
from langchain.embeddings import FakeEmbeddings
@pytest.fixture(scope="module")
def perf_vectorstore():
embeddings = FakeEmbeddings(size=384)
vs = Chroma(
collection_name="perf_test",
embedding_function=embeddings,
persist_directory="/tmp/test_chroma"
)
# 预热连接池
vs.add_texts(["warmup"], metadatas=[{"session_id": "warm"}])
return vs
def test_batch_insert_latency(perf_vectorstore):
texts = [f"perf test doc {i}" for i in range(200)]
start = time.perf_counter()
perf_vectorstore.add_texts(
texts,
metadatas=[{"session_id": "perf"}] * len(texts)
)
elapsed = time.perf_counter() - start
# 单条写入平均耗时不宜超过 15ms(含 embedding + 索引更新)
avg_latency_ms = (elapsed / len(texts)) * 1000
assert avg_latency_ms < 15, \
f"批量写入性能退化:平均 {avg_latency_ms:.2f}ms/doc,请检查连接池或硬件"
踩坑记录:文档不会告诉你的三个血泪教训
坑 1:clear() 只清 buffer,不清向量库 ------ 测试状态互相污染
现象 :第一个测试跑完执行 memory.clear(),第二个测试仍能搜到上一条的记忆片段。
根因 :LangChain 的 ConversationBufferMemory.clear() 只清除 chat_memory.messages 列表,根本没操作底层 vectorstore。你以为是全新环境,实则旧 embedding 还躺在 Chroma 里。
解决 :直接操作底层存储的 delete_collection() 或在每个测试的 fixture tear down 里按 session_id 精确删除。千万不要在集成测试里依赖 memory.clear()。
坑 2:默认相似度阈值是埋在 search_kwargs 里的隐式炸弹
现象:测试环境能召回 3 条结果,到线上同样输入只返回 1 条,导致 bot 回答变"失忆"。
根因 :VectorStoreRetriever 的 search_kwargs["score_threshold"] 如果不显式指定,Chromadb 内部会走自己的默认逻辑(可能因版本而异)。更坑的是,FakeEmbeddings 产生的向量相似度与实际模型完全不同,测出来的阈值毫无参考价值。
解决 :测试中要么不使用 score_threshold,要么使用确定性 embedding 并固定阈值断言。线上则务必通过 A/B 测试产出专用阈值。
坑 3:asyncio 测试 fixture 不 scope,连接池撑爆 CI
现象 :本地单测全绿,GitHub Actions 上跑到第 6 个测试开始报 TimeoutError。
根因 :pytest-asyncio 默认 session 级 fixture 无法在多个 event loop 间安全共享 Chroma client。每个测试自动重建 client,连接数暴涨,打满 Chroma 容器的 gRPC 连接池。
解决 :将 vectorstore fixture 声明为 scope="session",并在 conftest.py 里使用 @pytest.fixture(scope="session") 和 event_loop fixture 配合,保证整个测试会话只用一个 client。
效果验证
| 指标 | 自动化测试前 | 自动化测试后 |
|---|---|---|
| 线上记忆错乱事故/月 | 2 次 | 0 次 |
| CI 发现连接池退化 | 无感知,上线后才发现 | 构建阶段拦截,提前回退 |
| 回归测试耗时 | 手工 curl 验证 15 分钟 | 3 分 12 秒(全量集成测试) |
CI 里现在有一条红色的性能基线曲线,上个月一个"优化"导致 add_texts P99 延迟从 12ms 升到 80ms,立即被拉响警报------如果没有这个测试,它会在下一个周末高峰期把我们打穿。
立刻拿去用的工具箱
复制这段 fixture 到你的 conftest.py,一键启动 Chroma Docker + 集成测试骨架:
python
# conftest.py
import pytest
import subprocess
from langchain.vectorstores import Chroma
from langchain.embeddings import FakeEmbeddings
@pytest.fixture(scope="session")
def chroma_container():
# 确保 8000 端口可用
subprocess.run("docker run -d -p 8000:8000 chromadb/chroma", shell=True)
@pytest.fixture(scope="session")
def vectorstore(chroma_container):
embeddings = FakeEmbeddings(size=384)
vs = Chroma(
collection_name="auto_test",
embedding_function=embeddings,
chroma_server_host="localhost",
chroma_server_http_port="8000"
)
yield vs
vs.delete_collection()
#Python #LLM #自动化测试 #向量数据库 #后端踩坑
关于作者
我是宝福,一个在 AI 应用层反复踩坑的实战派后端/架构工程师,目前专注 LLM 工程化落地。
GitHub: github.com/baofugege (分享记忆测试套件与工具)
Sponsor: github.com/sponsors/ba... --- 如果文章帮你省了凌晨排查的时间,欢迎请我喝杯咖啡
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege