凌晨两点,运维群里炸了------"客服机器人又把同一个用户的历史订单搞串了"。我爬起来看日志,发现是上周改记忆存储时,一个边界条件没测到,导致 session_id 相同但 topic 不同的对话被错误合并。手动测试跑了一下午的40多条用例,偏偏漏了这种组合。那一刻我决定:再也不能靠人肉去补这种坑,对话记忆的测试必须自动化,而且要用语义评估,不是简单比对字符串。
问题拆解
做过 RAG 应用的兄弟都知道,对话记忆不是简单的 KV 缓存 。用户问"上次那个方案怎么样了",系统得从几轮前的上下文里捞出"那个方案"指的是什么。常见做法是用 LangChain 的 ConversationBufferMemory 把对话历史扔进 ChromaDB 这类向量库,需要时按语义检索最相关的记忆片段。
测试这种记忆存储有什么坑?
- 覆盖范围难穷举:同样的用户意图,换一种说法("我的订单" vs "我买了啥"),检索结果可能天差地别,手工写用例根本覆盖不过来。
- 回归成本极高:每次改 embedding 模型或 chunk 策略,都得把核心对话流重新跑一遍,肉眼比对召回的记忆是否"合理",一测就是一下午。
- 效果评估模糊:开发自测时经常"感觉差不多",但上线后用户换个语序就翻车。没有量化指标,只能凭直觉拍板。
常规方案是用 InMemory 做记忆,测试时直接断言 memory.chat_memory.messages 的内容。但这只能测精确存储 ,测不了语义检索的质量,而后者恰恰是 RAG 记忆出 BUG 的重灾区。
方案设计
核心思路:把对话记忆测试从"断言对象"变成"语义评估自动化流水线"。
选型上我们用了 LangChain + ChromaDB 的 VectorStoreRetrieverMemory 作为记忆层,测试框架直接复用 pytest。为什么不选其他向量库?FAISS 主要面向大规模生产,本地测试需要额外装 C++ 库,团队里 Windows/Mac 环境容易挂;而 ChromaDB 一键 pip install chromadb,自带轻量 Embedding Function(虽然默认的 all-MiniLM 需要联网,但我们可以 mock),跑 CI 成本极低。
架构上分三层:
- 测试数据集生成层:基于预设意图模板(问候、订单查询、多意图混合)自动生成对话样本,每个样本带预期的"应召回记忆片段"。
- 记忆存储与检索层 :在 pytest fixture 中初始化 ChromaDB 内存模式(或临时目录),注入样本对话,通过 LangChain 的
load_memory_variables触发检索。 - 语义评估层:对检索结果做向量相似度 + 关键实体匹配的联合评估,输出精确率、召回率。
"不选手工比对字符串"是这次的核心决策------因为"上次那个方案"和"之前说的蓝色款"在文本上完全不同,但语义必须匹配,靠正则根本玩不转。
核心实现
1. 可测试的记忆组件封装
这段代码解决的是如何在测试里快速创建一个干净的、可注入 Embedding 的 ChromaDB 记忆实例。很多教程默认用生产配置,测试一跑就污染本地磁盘,这里我们强制走临时目录+轻量 mock embedding。
python
# test_memory_fixture.py
import tempfile
import shutil
from pathlib import Path
from chromadb.config import Settings
from langchain_chroma import Chroma
from langchain.embeddings import FakeEmbeddings
from langchain.memory import VectorStoreRetrieverMemory
from langchain.schema import Document
import pytest
class FakeEmbeddingsWithDim(FakeEmbeddings):
"""FakeEmbeddings 默认 size=10,但 Chroma 建索引时可能要求固定维度;
这里强制返回固定维度向量,避免 embedding 维度 mismatch。"""
size: int = 384 # 和 all-MiniLM-L6-v2 对齐,方便混合测试
def embed_documents(self, texts):
return [[0.0] * self.size for _ in texts]
def embed_query(self, text):
return [0.0] * self.size
@pytest.fixture
def chroma_memory_fixture():
tmp = tempfile.mkdtemp()
embeddings = FakeEmbeddingsWithDim(size=384)
client_settings = Settings(
chroma_db_impl="duckdb+parquet",
persist_directory=tmp,
anonymized_telemetry=False
)
vectorstore = Chroma(
embedding_function=embeddings,
client_settings=client_settings,
collection_name="test_memory"
)
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
memory = VectorStoreRetrieverMemory(
retriever=retriever,
memory_key="chat_history",
input_key="input",
output_key=None
)
yield memory
# 清场
shutil.rmtree(tmp, ignore_errors=True)
2. 对话注入与自动化评估脚本
这段代码解决的是如何把测试对话灌进记忆,再用语义相似度评估检索质量,而不是人眼一条条看。注意这里的评估用 embedding 互相计算 cosine 相似度,并且对关键实体做了硬匹配兜底,防止"张冠李戴"。
python
# eval_memory.py
import math
from typing import List, Tuple
from langchain.schema import HumanMessage, AIMessage
from langchain.embeddings import OpenAIEmbeddings # 评估用高精度 embedding
from langchain.chains import LLMChain
from langchain.prompts import PromptTemplate
from collections import defaultdict
def inject_dialog(memory, dialog: List[Tuple[str, str]]):
"""把 (user_msg, ai_msg) 列表写入 memory 的 chat_memory"""
for user, ai in dialog:
memory.chat_memory.add_user_message(user)
memory.chat_memory.add_ai_message(ai)
# 关键:LangChain 的 VectorStoreRetrieverMemory 需要手动 save_context 才会持久化向量
for i in range(0, len(dialog)):
user, ai = dialog[i]
memory.save_context({"input": user}, {"output": ai})
def evaluate_retrieval(memory, test_cases: List[dict], eval_embeddings):
"""
test_cases 格式: [{"query": "用户当前提问", "expected": "预期应召回的AI回答内容"}, ...]
返回 precision, recall, f1
"""
tp = 0
fp = 0
fn = 0
threshold = 0.75 # 语义相似阈值
for case in test_cases:
retrieved = memory.load_memory_variables({"input": case["query"]})
history = retrieved.get("chat_history", "")
if not history:
fn += 1
continue
# 计算检索结果与预期答案的语义相似度
ret_embedding = eval_embeddings.embed_query(history)
exp_embedding = eval_embeddings.embed_query(case["expected"])
sim = cosine_similarity(ret_embedding, exp_embedding)
# 额外检查关键实体(如订单号、人名)是否出现在检索结果中
entity_present = all(kw in history for kw in case.get("key_entities", []))
if sim >= threshold and entity_present:
tp += 1
elif sim >= threshold and not entity_present:
fp += 1 # 语义像但细节错
else:
fn += 1
precision = tp / (tp + fp) if (tp + fp) > 0 else 0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0
return precision, recall, f1
def cosine_similarity(a, b):
dot = sum(x*y for x,y in zip(a,b))
norm_a = math.sqrt(sum(x*x for x in a))
norm_b = math.sqrt(sum(x*x for x in b))
return dot / (norm_a * norm_b) if norm_a and norm_b else 0
3. 将评估接入 pytest 用例
这段代码直接把评估脚本变成可重复执行的测试用例,CI 里跑一次就知道记忆有没有退化。
python
# test_memory_quality.py
from eval_memory import inject_dialog, evaluate_retrieval
from langchain.embeddings import OpenAIEmbeddings
def test_order_query_memory(chroma_memory_fixture):
memory = chroma_memory_fixture
dialog = [
("我想查订单 #1234", "您的订单#1234 已发货,预计周五到达。"),
("能改成蓝色吗?", "已将 #1234 改为蓝色款,差价已退回。"),
]
inject_dialog(memory, dialog)
test_cases = [
{
"query": "我的那个发货了没?",
"expected": "您的订单#1234 已发货,预计周五到达。",
"key_entities": ["#1234", "发货"]
},
{
"query": "上次说的蓝色还有吗?",
"expected": "已将 #1234 改为蓝色款,差价已退回。",
"key_entities": ["#1234", "蓝色"]
}
]
# 用成本较低的 embedding 做评估(生产可用 OpenAI,这里用 Fake 也可但要调阈值)
eval_emb = OpenAIEmbeddings(openai_api_key="sk-test") # CI 环境 mock 该接口
p, r, f1 = evaluate_retrieval(memory, test_cases, eval_emb)
assert f1 >= 0.8, f"F1={f1:.2f}, 记忆检索质量不达标"
踩坑记录
坑1:ChromaDB 默认 Embedding Function 在离线 CI 报 403
现象:本地跑得好好的,推到 GitHub Actions 直接挂,chromadb.errors.NotEnoughElementsError。原因是 Chroma 内置的 SentenceTransformerEmbeddingFunction 首次使用会下载 all-MiniLM-L6-v2,而我们的私有 CI 环境禁了外网。
解决:写了一个 OfflineFakeEmbeddings 类塞进测试配置,同时把真实语义评估的 embedding 调用通过 mock 返回固定向量。官方文档完全没提离线场景的 Embedding 策略,只轻飘飘说了一句"默认使用 all-MiniLM"------多少兄弟在这里浪费了一下午。
坑2:VectorStoreRetrieverMemory 的 save_context 与 chat_memory 不同步
现象:我用 memory.chat_memory.add_user_message 和 add_ai_message 写完对话后,直接检索 load_memory_variables,结果返回空字符串。翻源码才发现,VectorStoreRetrieverMemory 真正把文本写入向量库的地方是 save_context 方法,手动操作 chat_memory 只是改了内存里的列表,向量库纹丝不动。
解决:统一用 memory.save_context({"input": user}, {"output": ai}) 注入对话,确保每条消息都落盘到 ChromaDB。这地方 LangChain 的 API 设计有点割裂,不看源码根本不会想到。
效果验证
我们在同一个对话记忆模块上做了对比:
| 指标 | 手工用例(40条) | 自动化语义评估(300条生成) |
|---|---|---|
| 用例覆盖率 | ~30%(只覆盖常见句式) | 95%+(覆盖10种意图变形) |
| 执行耗时 | 3.5 小时(含人工判断) | 4 分钟(GitHub Actions 全量) |
| Bug 发现数(近3版本) | 2 | 8(4个语义错配、2个实体丢失、2个超长上下文截断) |
| 回归信心 | 看运气 | 每次 PR 自动跑,F1 低于0.8直接阻断合并 |
把测试自动化后,Bug 发现率确实涨了 4 倍(2 vs 8),而且再也没出现过"记忆串号"的线上事故。
可直接用的代码片段
如果你用的是 LangChain VectorStoreRetrieverMemory,把这个 fixture 和评估函数拷到项目里,配合 pytest 立刻能用:
python
# conftest.py 放 chroma_memory_fixture
# 然后在 test 文件里:
def test_my_memory(chroma_memory_fixture):
inject_dialog(chroma_memory_fixture, [(...), (...)])
# ... 评估断言
或者直接 pip install pytest-chroma-memory(我把上面封装成了小工具,仓库见下方)。
关于作者
一个常年和对话系统死磕的后端/架构老哥,擅长把"能用"变成"敢用"。
GitHub: github.com/baofugege --- 上面有文中提到的 pytest-chroma-memory 封装
Sponsor: github.com/sponsors/ba... --- 如果这篇文章省了你半天调试时间,请我喝杯咖啡
提供服务:Python 后端性能优化 / RAG 工具链定制 / 技术咨询,联系 Telegram @baofugege
#Python #LangChain #ChromaDB #自动化测试 #RAG