把大模型记忆回归测试从手工检查换成 Playwright+VCR,误判率从 40% 降到 0

凌晨一点,我被一连串钉钉消息震醒。产品经理在群里大喊:"用户投诉我们 AI 助手把他上周设定的'紧急联系人'全忘了,明明上次对话里还提到过!"我迷迷糊糊打开日志,一条条翻聊天记录,发现模型这次回答确实跟历史记忆对不上------典型的记忆幻觉,但上一个版本明明没这个问题。谁改了记忆存储的逻辑?没人承认。那一夜,我手动对比了 30 组对话,眼睛都花了,最后靠直觉拍板:回滚。之后我在想,为什么我们在 2025 年还在靠"人肉"比对大模型的记忆? 这直接催生了 Playwright + VCR 的自动回归方案。

问题拆解:大模型记忆测试为什么这么难

我们的产品有一个"长记忆"功能:模型会记住多轮对话中用户的个性化信息(姓名、偏好、最近讨论的项目),并在后续交互中调用。记忆存储层用向量数据库 + 摘要引擎实现,每次升级记忆提取逻辑、更换 embedding 模型或调整 prompt,都可能引入回归------要么丢失记忆条目,要么产生完全错误的新记忆(幻觉)。手工回归的流程是:打开聊天窗口,扮演用户按既定脚本提问,再人工核对模型是否记住了该记的信息,没有胡编乱造。这个流程有三个致命痛点:

  1. 耗时且不可靠:一轮 20 条对话的脚本,熟练工也要跑 15 分钟,而且人到第四轮就会疲劳,相似对话来回比对,误判率超高------我们的产品经理曾经把一个幻觉回答标记成"没问题",因为"看起来挺合理的"。
  2. 覆盖面窄:只能抽测典型路径,改了一行记忆去重代码,根本不知道会不会影响边缘长对话。
  3. 环境差异:本地测得好好的,上了 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

相关推荐
倾颜8 小时前
pnpm 负责依赖,Turbo 负责任务:一次小型 Monorepo 治理的边界取舍
前端·next.js
程序员爱钓鱼8 小时前
Rust 元组 Tuple 详解:组合不同类型的数据
前端·后端·rust
IT_陈寒9 小时前
React的useEffect为什么经常执行两次?
前端·人工智能·后端
国服第二切图仔9 小时前
12-Web工具
大数据·前端
爱分享的程序猿-Clark9 小时前
【前端分享】大前端监控体系搭建实战
前端
夕除9 小时前
sign 是什么
java·前端
An_s9 小时前
Android仿真翻页(一),基于pagecurl二开
前端·javascript·html
weixin_462901979 小时前
网页 HTML ↔ ESP32 完整通信体系详解
前端·html
禅思院9 小时前
Agent记忆管理,是一场工程上的平衡艺术
前端·架构·ai编程