把 LLM 记忆回归测试的误判率从 30% 降到 0,我做了这三件事

2 阅读1分钟

凌晨两点,手机震得比项目上线还猛。打开告警——测试用例红了,142 条失败里 103 条是“对话内容不一致”。看日志,实际返回:“好的,你上次提到喜欢《三体》” ,断言期望却是:“好的,我记得你喜欢《三体》”。就多了一个“你上次提到”,LLM 的自然语言随机性把回归测试打得稀碎。那一刻我知道,如果再不换思路,整个 CI 管线迟早被搞成“狼来了”,没人再相信测试。

问题拆解:为什么常规测试总会误判?

场景很简单:我们的 LLM 聊天产品有一个“记忆存储”功能——用户说过喜欢的书、个人偏好,下次开启新会话时模型能主动 recall 出来。产品上线后,记忆模块迭代频繁,每次发版都可能改存储结构、缓存策略或召回 prompt。我们需要一套自动化回归,确保“曾经记住的东西,刷新页面后仍然准确展示,不会丢、不会错乱”。

最初的测试方案很“直觉”:用 Playwright 模拟对话 → 发送一句“我最喜欢的书是《三体》” → 刷新页面 → 再发送“你还记得我喜欢什么书吗” → 检查回复中是否包含《三体》。问题出在最后一步:我们把断言绑在了 LLM 的生成内容上。模型微调、prompt 调整甚至温度参数变化,都可能导致表达方式不同,让文本断言直接崩掉。试过用 Sentence-BERT 算语义相似度设阈值,的确从 50% 误判降到 30%,但阈值调再低也免不了边界 case,依然有假阳性。

根因是:测试不应该是测“模型说了什么”,而应该测“系统存了什么、展示了什么”。记忆存储的最终呈现,其实已经在 UI 的历史记录气泡里了。如果我们的回归只验证“刷新后历史气泡中的文本是否与发送内容完全一致”,那就跟 LLM 随机性完全解耦——0 误判。

方案设计:把断言迁到 UI 历史记录上

既然记忆会 render 在聊天历史里,我们只需要做三步:

  1. 发送特定消息,比如 set memory: favorite_book=三体
  2. 刷新页面(或重新登录),等待历史消息加载完毕;
  3. 断言某个历史气泡里的文本 完全等于 发送的消息原文。

这样做纯字符串精确比对,没有模型干扰,误判率为零。技术选型上:

  • Playwright:比 Selenium 更适合现代单页应用,自带智能等待、网络监控,处理 websocket 和 SPA 路由游刃有余。
  • pytest:fixture 机制天然适合管理浏览器实例、用户登录态,参数化可以轻松覆盖多组记忆。
  • 为什么没用 API 直调用?因为历史记录的渲染依赖前端对存储格式的解析(比如 timestamp、metadata 去重),后端正确不代表 UI 正确,端到端回归必须验证最终 DOM。

另外,为了保证真正 0 误判,我们还约定:测试用的消息采用固定格式 set key: value,断言完全等值。这样无论产品 UI 怎么改气泡样式,只要文本节点内容对,测试就稳如磐石。

核心实现:一步一步把方案落地

1. 用户登录 fixture 与数据隔离

下面这段 conftest.py 解决两个问题:每次测试都用独立账号,避免并行测试的记忆互相污染;同时用 page.context.storage_state() 持久化登录态,避免重复登录浪费时间。

# conftest.py
import pytest
from playwright.sync_api import Page, BrowserContext, Playwright

@pytest.fixture(scope="session")
def browser_context(playwright: Playwright) -> BrowserContext:
    # 启动无头浏览器,关闭缓存以确保刷新后拉取最新历史
    browser = playwright.chromium.launch(headless=True)
    context = browser.new_context(
        viewport={"width": 1280, "height": 720},
        # 禁止缓存,防止历史记录未刷新导致漏测
        ignore_https_errors=True,
        storage_state=None,
    )
    yield context
    context.close()
    browser.close()

@pytest.fixture
def logged_in_page(browser_context: BrowserContext, request: pytest.FixtureRequest) -> Page:
    # 使用参数化动态生成唯一用户,避免数据交叉
    user_id = request.node.name  # 例如 test_basic_memory,确保唯一
    page = browser_context.new_page()
    page.goto("https://your-llm-app.com/login")
    # 简化:假设输入邮箱即可注册/登录
    page.fill("input[data-testid='email-input']", f"{user_id}@test.com")
    page.click("button[data-testid='login-btn']")
    page.wait_for_selector("[data-testid='chat-area']")
    yield page
    # 测试后清理:可选把该用户记忆数据删除(调用 API 或用 UI)
    page.close()

2. 封装记忆发送与历史验证工具函数

下面的工具函数用来发送带有固定前缀的记忆消息,并且提取历史记录容器中的所有文本节点。这样做的好处是:后续用 in 或精确匹配时,代码简洁到一眼能看出期望。

# utils.py
from playwright.sync_api import Page

MEMORY_PREFIX = "set memory:"

def send_memory(page: Page, key: str, value: str) -> None:
    """发送一条记忆设置消息,格式:set memory: key=value"""
    payload = f"{MEMORY_PREFIX} {key}={value}"
    page.fill("[data-testid='message-input']", payload)
    page.click("[data-testid='send-btn']")
    # 等自己的气泡出现
    page.wait_for_selector(f"[data-testid='message-bubble']:has-text('{payload}')")

def get_chat_history_texts(page: Page) -> list[str]:
    """获取当前所有对话气泡的可见文本,返回列表"""
    bubbles = page.query_selector_all("[data-testid='message-bubble']")
    return [b.inner_text().strip() for b in bubbles]

3. 回归测试用例:刷新前后记忆一致性

这才是真正的测试逻辑。参数化多组记忆,刷新后确认历史记录中含有我们发送的完整字符串。

# test_memory_regression.py
import pytest
from utils import send_memory, get_chat_history_texts

@pytest.mark.parametrize("key,value", [
    ("favorite_book", "《三体》"),
    ("pref_language", "Rust"),
    ("last_question", "深度学习的优化器有哪些"),
])
def test_memory_survives_reload(logged_in_page, key, value):
    page = logged_in_page
    expected_message = f"set memory: {key}={value}"

    # 1. 发送记忆
    send_memory(page, key, value)

    # 2. 刷新页面,模拟重新进入应用
    page.reload(wait_until="networkidle")
    # 等待历史区域加载完毕(关键!)
    page.wait_for_selector("[data-testid='chat-history-panel']")
    # 再次等待消息气泡至少出现一条,避免空白页断言
    page.wait_for_selector(f"[data-testid='message-bubble']:has-text('{expected_message}')")

    # 3. 获取历史全部文本,做精确断言
    history_texts = get_chat_history_texts(page)

    # 核心断言:历史记录中必须包含该字符串,且出现位置、格式与发送一致
    assert expected_message in history_texts, (
        f"刷新后未找到记忆消息: {expected_message}\n"
        f"当前历史记录: {history_texts}"
    )

你看,断言完全不碰模型输出,只检查 UI 历史文本。发送的是 set memory: favorite_book=《三体》,刷完后依然能抓到这条一模一样的字符串,这就证明了记忆存储的持久化链路是完整的——写入→存储→读取→渲染,零随机性。

踩坑记录:那些官方文档没教我的事

坑 1:SPA 刷新后,历史加载太慢,断言跑飞

现象:page.reload() 后立即查询气泡,发现列表为空,误报“记忆丢失”。实际上前端还在拉取 /api/history,React 还在执行渲染。

解决:不能单靠 wait_until="networkidle",因为 websocket 长连接可能让 networkidle 迟迟不到。最终采用双重等待:先等面板容器出现 [data-testid='chat-history-panel'],再等包含具体消息内容的气泡出现。这就是上面代码里连续两个 wait_for_selector 的由来。官方文档推荐 expect(locator).to_have_text 有自动重试,但对复杂 SPA 还是直接 wait_for_selector 更可控。

坑 2:Headless 模式下浏览器缓存导致历史记录“回光返照”

现象:偶尔刷新后仍然能查到上次运行的记忆,导致错误推断测试成功,其实当前数据库早已清空。原因:chromium 的 disk cache 缓存了上一条 session 的 API 响应。

解决:创建 context 时设置 ignore_https_errors=True 不够,还需要在测试中主动调用 page.route 拦截请求加 Cache-Control: no-cache 头,或者每次运行测试前清空浏览器 user data 目录。我们最终选择在登录后注入 header:

page.route("**/*", lambda route: route.continue_(
    headers={**route.request.headers, "Cache-Control": "no-cache"}
))

这招文档里没明说,对端到端测试环境极其重要。

效果验证:从 30% 误判到零,团队信任重建

改造前 200 组记忆回归用例,随机性导致平均 60 条误报,开发经常忽略测试结果。改造后,将断言迁移到 UI 历史记录精确比对,配合数据隔离和缓存控制,误判率从 30% 直降到 0,连续 3 周无任何假阳性。更关键的是,这套测试真正捕获了两起记忆丢失 Bug:一次是后端修改了 time-to-live 字段名导致前端解析失败,另一次是刷新接口时排序错乱。这才是回归测试该有的价值——要嘛全绿,要嘛有问题

直接拿去用的模板

# 安装依赖后直接跑
pip install pytest-playwright playwright
playwright install --with-deps
pytest test_memory_regression.py --headed

或者只想用核心 fixture:复制 conftest.pyutils.py,把登录逻辑换成你们产品的选择器,即可零误判验证 LLM 记忆持久化。


#Python #Playwright #pytest #LLM #自动化测试

关于作者
一个一直在后端与端到端测试间折腾的架构工程师,坚信好的测试比好代码更难写。
GitHub: github.com/baofugege — 有更多测试工具和模板等你来提 issue。
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你省了凌晨两点的报警,请我喝杯咖啡。
提供服务:Python 后端性能优化 / 自动化测试框架定制 / 技术咨询,联系 Telegram @baofugege