凌晨一点,我被一连串钉钉消息震醒。产品经理在群里大喊:"用户投诉我们 AI 助手把他上周设定的'紧急联系人'全忘了,明明上次对话里还提到过!"我迷迷糊糊打开日志,一条条翻聊天记录,发现模型这次回答确实跟历史记忆对不上------典型的记忆幻觉,但上一个版本明明没这个问题。谁改了记忆存储的逻辑?没人承认。那一夜,我手动对比了 30 组对话,眼睛都花了,最后靠直觉拍板:回滚。之后我在想,为什么我们在 2025 年还在靠"人肉"比对大模型的记忆? 这直接催生了 Playwright + VCR 的自动回归方案。
问题拆解:大模型记忆测试为什么这么难
我们的产品有一个"长记忆"功能:模型会记住多轮对话中用户的个性化信息(姓名、偏好、最近讨论的项目),并在后续交互中调用。记忆存储层用向量数据库 + 摘要引擎实现,每次升级记忆提取逻辑、更换 embedding 模型或调整 prompt,都可能引入回归------要么丢失记忆条目,要么产生完全错误的新记忆(幻觉)。手工回归的流程是:打开聊天窗口,扮演用户按既定脚本提问,再人工核对模型是否记住了该记的信息,没有胡编乱造。这个流程有三个致命痛点:
- 耗时且不可靠:一轮 20 条对话的脚本,熟练工也要跑 15 分钟,而且人到第四轮就会疲劳,相似对话来回比对,误判率超高------我们的产品经理曾经把一个幻觉回答标记成"没问题",因为"看起来挺合理的"。
- 覆盖面窄:只能抽测典型路径,改了一行记忆去重代码,根本不知道会不会影响边缘长对话。
- 环境差异:本地测得好好的,上了 staging 环境因为模型版本、API 延迟不同,记忆提取结果也可能不同,手工完全没法覆盖。
常规方案有哪些?单元测试直接 mock 大模型 API,只能验证记忆存储层的代码逻辑,但真实模型输出的不可预测性、前端交互的时序、流式渲染对记忆提取的触发时机,全都测不到。用 Selenium 写脚本呢?能操控浏览器,但断言记忆内容需要写一堆 sleep 和脆弱的选择器,维护成本比被测系统还高。我们需要的是一套像录像回放一样,把用户与 AI 的完整交互过程固化成基准,每次回归自动对比,偏差就是 bug 的方案。
方案设计:为什么是 Playwright + VCR 模式
选型思路很简单:用 Playwright 驱动真实浏览器,模拟用户在聊天界面输入、点击、等待流式响应,与此同时,借鉴 VCR(Video Cassette Recorder)思想,把每一次测试运行的"对话序列 + AI 响应文本 + 关键 DOM 快照"录制下来。第一次运行设为 录制模式 ,生成基线(baseline);后续回归测试自动进入 回放模式,将当前运行结果与基线逐条比对,任何文本差异、记忆遗漏或幻觉污染都会被立刻捕获。
为什么不用其他方案?
- Cypress:对多 Tab、WebSocket 流式更新的支持不如 Playwright 原生,我们聊天接口就是 SSE 流。
- pytest-vcr 直接 mock HTTP :会把 LLM 的 API 调用也录制下来回放,这样确实不消耗 token,但也意味着永远在测同一份旧的模型响应,无法发现因为模型升级或记忆 prompt 改动引入的新幻觉。
- 纯截图对比(Visual Regression):AI 生成文本长度飘忽不定,滚动位置一变整个像素就全挂,误报率能让你怀疑人生。所以我们只对比结构化文本和部分关键 CSS 类的存在性。
我们最终的架构:pytest + playwright 同步 API + 自研的轻量 VCR 工具,称为 ChatVCR。它把每轮对话的"用户输入、期望记忆断言、AI 实际回复"序列化成 JSON。基线文件存放在 tests/cassettes/,由测试用例函数名自动生成路径。CLI 提供 --vcr-record 参数控制录制/回放。
核心实现:一个能真正用的 ChatVCR 测试框架
下面的代码解决一个问题:如何让 Playwright 测试脚本自动录制对话基线,并在回归时精确比对,同时避免随机文本(时间戳、id)干扰。
1. ChatVCR 基类,负责录制与比对
这段代码定义了 ChatVCR 类,封装了对话数据的保存、加载和智能比对逻辑------关键在于比对时使用 strip_dynamic_text 函数,用正则清洗掉每次运行都可能变化的动态内容,否则基线永远对不齐。
python
# chat_vcr.py
import json
import re
from pathlib import Path
from typing import Any, Dict, List
class ChatVCR:
def __init__(self, cassette_path: str, record_mode: bool):
self.path = Path(cassette_path)
self.record_mode = record_mode
self.dialogues: List[Dict[str, Any]] = []
def add_turn(self, user_msg: str, assistant_response: str, expected_memory: str = ""):
"""录制一轮对话"""
self.dialogues.append({
"user": user_msg,
"assistant": self._clean(assistant_response),
"expected_memory": expected_memory
})
def save(self):
if self.record_mode:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps(self.dialogues, ensure_ascii=False, indent=2))
def load_expected(self) -> List[Dict[str, Any]]:
if not self.path.exists():
raise FileNotFoundError(f"Baseline not found at {self.path}, run with --vcr-record first")
return json.loads(self.path.read_text())
def assert_against_baseline(self):
"""回放模式:与基线逐条比对,发现差异即断言失败"""
expected = self.load_expected()
assert len(self.dialogues) == len(expected), \
f"对话轮次不一致: 实际{len(self.dialogues)} vs 基线{len(expected)}"
for i, (act, exp) in enumerate(zip(self.dialogues, expected)):
assert act["user"] == exp["user"], f"第{i}轮用户输入不一致"
# 核心比对:AI回复文本(清洗后)
assert act["assistant"] == exp["assistant"], \
f"第{i}轮助手回复差异:\n期望: {exp['assistant']}\n实际: {act['assistant']}"
# 可选:验证期望记忆是否出现在回复中
if exp["expected_memory"]:
assert exp["expected_memory"] in act["assistant"], \
f"第{i}轮未包含期望记忆 '{exp['expected_memory']}'"
@staticmethod
def _clean(text: str) -> str:
# 移除常见的动态模式:UUID、时间戳、会话ID等
text = re.sub(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', '<UUID>', text)
text = re.sub(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?', '<TIMESTAMP>', text)
return text
2. pytest fixture,自动决定录制/回放
这段代码通过 conftest.py 将 ChatVCR 集成到 pytest 生命周期中,根据命令行参数 --vcr-record 切换模式,每个测试用例拥有独立的 cassette 文件。
python
# conftest.py
import pytest
from pathlib import Path
from chat_vcr import ChatVCR
def pytest_addoption(parser):
parser.addoption("--vcr-record", action="store_true", default=False, help="Record mode for VCR cassettes")
@pytest.fixture
def vcr(request):
record = request.config.getoption("--vcr-record")
test_name = request.node.name
cassette_file = Path(__file__).parent / "cassettes" / f"{test_name}.json"
vcr_instance = ChatVCR(str(cassette_file), record_mode=record)
yield vcr_instance
# 测试结束后:录制则保存,回放则比对
if record:
vcr_instance.save()
else:
vcr_instance.assert_against_baseline()
3. 实际测试用例:模拟记忆存储与回归验证
下面这个测试函数模拟用户设定姓名、喜好,并验证后续对话中 AI 能准确回忆,且不会出现幻觉。Playwright 操控真实浏览器,等待流式响应完成,然后将每轮对话录入 VCR。
python
# test_memory_regression.py
from playwright.sync_api import sync_playwright, expect
def test_user_name_and_preference_memory(vcr):
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://chat.your-product.com") # 你的聊天地址
# 登录等前置操作(略)
# 第一轮:设定姓名
page.fill("[data-testid='chat-input']", "我叫王小明")
page.click("[data-testid='send-btn']")
# 等待流式输出完成:直到出现特定文本或结束标记
page.wait_for_selector("[data-testid='assistant-message']:has-text('记住了')")
assistant_reply_1 = page.text_content("[data-testid='assistant-message']").strip()
vcr.add_turn("我叫王小明", assistant_reply_1, expected_memory="王小明")
# 第二轮:询问名字
page.fill("[data-testid='chat-input']", "你还记得我叫什么吗?")
page.click("[data-testid='send-btn']")
page.wait_for_selector("[data-testid='assistant-message']:has-text('王小明')")
assistant_reply_2 = page.text_content("[data-testid='assistant-message']").strip()
vcr.add_turn("你还记得我叫什么吗?", assistant_reply_2, expected_memory="王小明")
# 第三轮:故意问一个可能引发幻觉的问题
page.fill("[data-testid='chat-input']", "我是不是说过紧急联系人是李明?")
page.click("[data-testid='send-btn']")
page.wait_for_selector("[data-testid='assistant-message']")
assistant_reply_3 = page.text_content("[data-testid='assistant-message']").strip()
# 基线中我们期望 AI 不能确认不存在的记忆(幻觉)
vcr.add_turn("我是不是说过紧急联系人是李明?", assistant_reply_3, expected_memory="")
browser.close()
运行方式:
bash
# 首次录制基线
pytest test_memory_regression.py --vcr-record
# 后续回归测试
pytest test_memory_regression.py
当记忆存储逻辑被人改坏,第二轮助手回复里丢失了"王小明",或者第三轮 AI 竟然说"是的,你设定过李明为紧急联系人",VCR 的比对会立即失败,精确指出哪一轮出了问题。
踩坑记录:官方文档没说的两个陷阱
坑 1:流式响应的等待策略不是 wait_for_timeout
最初我们以为简单 page.wait_for_timeout(5000) 就行,结果在 staging 环境网络抖动时,AI 响应还没渲染完就截取文本,导致基线录制不完整,回放永远失败。现象是偶尔轮次采集到的 assistant_reply 只有半句话,CI 变成随机抽风。原因:流式输出每个 token 之间间隔不可预测。解决 :利用 Playwright 的 wait_for_selector 等待消息容器中出现完整结束标记(比如产品中会在消息末尾加一个隐藏的 [data-complete="true"]),或者等"停止生成"按钮消失。这样无论延迟多高,都能抓到完整回复。
坑 2:动态内容清洗不够彻底,基线文件天天变
录制好基线后,第二天 CI 就挂了------因为 AI 回答里自带"今天是 2025-03-15"这样的日期。我们在 _clean 中只替换了 ISO 格式时间戳,忽略了自然语言日期。现象:每天第一次跑回归都报"助手回复差异",实际只是日期变了。解决 :在清洗函数里加入更激进的正则(比如替换所有数字日期格式),或者在做比对的 assert 时引入自定义比较器(对时间/ID 字段忽略差异)。更稳妥的办法:把预期记忆断言独立出来,不强制要求全文本完全相等,只验证核心记忆信息存在、幻觉信息不存在。
效果验证:从 40% 误判到零失误
我们统计了一个记忆功能迭代周期(两周)的数据:
| 指标 | 手工测试 | Playwright + VCR |
|---|---|---|
| 单次回归耗时 | 120 分钟 | 8 分钟 |
| 脚本覆盖对话轮次 | 30 轮(3 条场景) | 150 轮(全场景) |
| 误判率(人工错误) | 40% | 0% |
| 发现回归/幻觉 bug 数 | 2 个(漏了 5 个) | 7 个 |
最典型的一次,记忆摘要引擎升级后,模型把用户"喜欢简洁模式"错误转换成"喜欢极简模式",意思相近但判定为幻觉。VCR 直接报错,我们才避免了上线一个扭曲用户偏好的严重体验问题。
直接拿去用的命令
不想自己搭?把上面的 ChatVCR 类复制到项目里,安装依赖:
bash
pip install pytest-playwright
playwright install chromium
pytest --vcr-record # 录制基线
pytest # 回归守护
关于作者
一个专注于把大模型产品从"炼丹"拉回"工程可控"的后端 / 架构工程师,常在深夜与记忆幻觉和 CI 红灯搏斗。
GitHub: github.com/baofugege --- 文中完整 ChatVCR 工具及示例仓库稍后放出。
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你省下了熬夜比对聊天的时间,可以请我喝杯咖啡。
提供服务:Python 后端性能优化 / LLM 应用可观测性测试方案 / 技术咨询,联系 Telegram @baofugege
#Playwright #大模型测试 #自动化测试 #记忆回归 #Python