上周五临下班,产品经理幽幽地飘过来:"咱们那个 AI 客服,怎么老记不住用户上一轮说了啥?上个月明明修好了,刚才又翻车。"我打开日志一看,果然是对话记忆在某个分支场景下被意外清空了------这已经是本月第三次了。
事后复盘,根因不在于 LangChain 的 Memory 机制本身有什么大坑,而是我们缺乏一套能自动回归对话记忆的测试体系。每次改 prompt、切模型、调 chain 参数,都靠手工跑几句对话,根本覆盖不全多轮对话的边界情况。于是我花了整整一个周末,把 LangChain Memory 的自动化回归测试从 0 到 1 啃了下来。
这篇文章,就记录我如何用 pytest 搭起一套可复用的 Memory 回归测试方案,以及中间踩过的那些"官方文档没告诉你"的坑。
问题拆解:为什么手工测试 Memory 总是漏
大模型应用里的 Memory,本质上是一个在对话过程中持续写入和读取的状态存储。它不像传统 API 那样 input-output 确定------同一段对话历史,不同模型、不同 temperature,甚至相同模型不同时刻的回复都可能不同。这就导致两个难点:
- 记忆内容非确定性:你不能简单断言"第 N 轮之后 memory 里一定存了某段 exact 文本",因为 LLM 可能会用自己的话改写。
- 记忆边界难触发:Token 限制裁剪、摘要压缩、多用户隔离......这些逻辑在简单手工测试里几乎走不到分支。
常规的方案是 mock LLM,但这又背离了回归测试的本质------我们想抓的恰恰是真实 LLM 环境下记忆行为是否退化 。因此目标很明确:用真实 LLM(或可控的本地模型)跑多轮对话,然后对 Memory 的状态做语义级断言,而非字符级断言。
方案设计:为什么不直接用 LangChain 自带的测试工具
LangChain 社区版几乎没有现成的 Memory 测试套件。几条路摆在面前:
- 方案 A:用 LangSmith 的 Evaluation 功能。一来收费,二来线上依赖重,不适合本地 CI/CD 快速反馈。
- 方案 B:自己写一套基于
unittest的脚本。能跑,但扩展性差,参数化一轮对话要写大量重复代码。 - 方案 C:pytest + fixture + 自定义断言。pytest 的 fixture 体系天然适合管理共享的 chain 和 memory 对象;自定义断言配合少量语义匹配逻辑,可以写出"memory 中应包含用户地址信息"这类高可读性的用例。
最终选定方案 C。架构上拆成三层:
- Fixture 层:负责初始化 LLM(这里用本地 Ollama 的 qwen2 模型保证稳定可复现)、不同类型 Memory(ConversationBufferMemory、ConversationSummaryMemory 等)。
- Step 层:封装一轮"用户输入 → 模型输出 → memory 更新"的交互,返回本轮状态快照。
- Assert 层:通过 LLM 再一次做简单的是/否判断,或者用关键实体提取来做语义断言。
核心实现:从 fixture 到语义断言,逐步拆解
1. 这是 fixture 与基础对话函数,解决"如何复用模型和 memory"的问题
下面的 conftest.py 把模型和 memory 的初始化抽离出来,每个测试用例都可以按需注入不同参数。
python
# conftest.py
import pytest
from langchain_community.chat_models import ChatOllama
from langchain.memory import ConversationBufferMemory, ConversationSummaryMemory
from langchain.chains import ConversationChain
@pytest.fixture
def ollama_model():
# 固定参数,确保本地回归结果可重复
return ChatOllama(
model="qwen2:7b",
temperature=0, # 确定性输出,方便对比
top_p=0.1,
)
@pytest.fixture(params=["buffer", "summary"])
def memory(request, ollama_model):
"""参数化 fixture:一次性覆盖多种 Memory 实现"""
if request.param == "buffer":
return ConversationBufferMemory(return_messages=True)
elif request.param == "summary":
# 使用模型做摘要,这里传入同一个 ollama_model
return ConversationSummaryMemory(
llm=ollama_model,
return_messages=True,
)
接下来是一个通用的对话函数,它接受已构建好的 chain 和用户输入,执行一轮交互并返回更新后的 memory 内容。
python
# conftest.py (续)
def run_turn(chain: ConversationChain, user_input: str) -> dict:
"""执行一轮对话,并返回 memory 中的消息列表"""
_ = chain.run(user_input) # 忽略模型回复
# 直接从 chain.memory 读取全部历史消息
messages = chain.memory.load_memory_variables({})["history"]
return {
"message_count": len(messages),
"last_human_msg": user_input,
"history_text": " ".join([m.content for m in messages]),
}
2. 第一个测试:验证基础记忆能跨轮次保持
这段代码解决最核心的回归断言------"用户说过的事,后续轮次 memory 里到底还在不在"。
python
# test_memory_retention.py
import pytest
from langchain.chains import ConversationChain
@pytest.mark.parametrize("memory", ["buffer", "summary"], indirect=True)
def test_cross_turn_memory_retention(ollama_model, memory):
chain = ConversationChain(llm=ollama_model, memory=memory)
# 第一轮:明确告知关键信息
run_turn(chain, "我叫张三,我下周二要去北京出差。")
# 第二轮:故意引入噪音,试探记忆是否被冲掉
run_turn(chain, "今天天气真不错,适合出去玩。")
# 第三轮:返回去追问第一轮的信息
result = run_turn(chain, "我之前告诉过你我要去哪里出差?什么时候?")
# 语义断言:memory 中应包含"北京"和"下周"或"周二"
assert "北京" in result["history_text"]
assert any(word in result["history_text"] for word in ["周二", "下周", "星期二"])
注意,这里用的是简单的关键词断言。对于 buffer memory,关键词命中率极高;对于 summary memory,因为模型会压缩表述,我用 any(...) 做一个宽松匹配。这也是后续要进化为语义断言的地方。
3. 第二个测试:验证 Memory 在上下文超长时的裁剪行为
官方文档对 max_token_limit 的描述一嘴带过,实际落地才发现,消息裁剪的时机和策略在不同参数下差异非常大。下面这个用例专门用来回归"旧信息被移除后,新信息不应受影响"。
python
# test_memory_trimming.py
import pytest
from langchain.chains import ConversationChain
from langchain.memory import ConversationTokenBufferMemory
from langchain_community.chat_models import ChatOllama
@pytest.fixture
def token_model():
return ChatOllama(model="qwen2:7b", temperature=0)
def test_token_buffer_trimming_not_losing_recent_context(token_model):
# 设置极小的 token 上限,强制触发裁剪
memory = ConversationTokenBufferMemory(
llm=token_model,
max_token_limit=40, # 故意设低,模拟极端场景
return_messages=True,
)
chain = ConversationChain(llm=token_model, memory=memory)
# 连续塞入大段信息,迟早触发 token 裁剪
run_turn(chain, "我的信用卡号是 1234-5678-9012-3456")
run_turn(chain, "我家的地址是上海市浦东新区张江高科技园区某某路 100 号")
run_turn(chain, "我刚想起来,卡号后面还有安全码 321")
# 最后追问:理论上信用卡号和地址只能保留其一(token 限制)
result = run_turn(chain, "你还能查到我的信用卡号吗?")
# 核心断言:最近输入的"安全码"一定在,因为它至今仍在 token 窗口内
assert "321" in result["history_text"]
# 可选宽松断言:信用卡号或者地址至少有一个被保留(或部分保留)
assert ("1234" in result["history_text"] or "张江" in result["history_text"])
踩坑记录
坑1:load_memory_variables({}) 在 SummaryMemory 下返回空字典
现象 :第一轮对话结束后立即调用 memory.load_memory_variables({}),ConversationSummaryMemory 返回的字典里 history 键对应的值竟是空字符串,导致所有断言失败。
原因 :ConversationSummaryMemory 只有在第二次 调用 chain 时才会真正触发摘要并存入 moving_summary_buffer。首轮对话结束后,摘要变量尚未生成。
解决 :在所有测试中,至少跑两轮对话后才开始做 memory 状态断言。这个 behavior 在源码里藏得很深,官方示例也只用 predict 方式演示,初次接触极易踩坑。
坑2:ConversationTokenBufferMemory 的 token 计数依赖 LLM 回调
现象 :用 ChatOllama 初始化 ConversationTokenBufferMemory,设置了 max_token_limit=100,但跑了 20 轮对话都没触发裁剪。
原因 :ConversationTokenBufferMemory 内部通过 get_num_tokens_from_messages 计算 token 数,默认调用 tiktoken 编码器。但 ChatOllama 模型是 qwen,它的 tokenizer 与 tiktoken 不匹配,导致每次都返回估算值 0,裁剪逻辑彻底失效。
解决 :强行注入一个自定义的 count_tokens 函数,简单用字符数/2 粗略估算,或者干脆换用原生支持 token 计数的模型(如 OpenAI API)。我在测试中用 ChatOpenAI 替代本地模型跑这部分用例,确保计数准确。
效果验证
在没有这套测试之前,我们发版后 Memory 相关的缺陷反馈平均要3 天 才能从用户渠道漏出;接入 CI 后,每次改动在 5 分钟内就能跑完 12 个核心回归用例,覆盖 buffer/summary/token-buffer 三种 Memory 类型和 4 个典型对话场景(个人信息保持、上下文裁剪、多用户隔离、摘要质量)。
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 回归周期 | 手工测试,>2 小时 | 自动化,5 分钟 |
| 缺陷发现阶段 | 生产环境 | CI 阶段 |
| 可覆盖 Memory 类型 | 1 种(buffer) | 3 种 |
可直接用的代码/工具
我把上面的 fixture 和关键测试用例打包成了一个独立的 test_memory.py 模板,你只需要替换成自己的 LLM 实例,再根据业务场景补充断言逻辑,就能立刻在项目里跑起来。运行只需一行:
bash
pytest -v test_memory.py --tb=short
#Python #LLM #LangChain #测试 #自动化回归
关于作者
我是宝富,一个在 AI 应用落地和系统稳定性上反复横跳的后端/架构程序员。除了写代码,我还喜欢琢磨怎么让大模型在生产环境中更"靠谱"。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba... --- 如果这篇文章让你少熬一个周末,可以请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege