大模型长上下文遗忘排查实录:用 Playwright 自动化测试,揪出了 90% 的存储序列化 bug

4 阅读8分钟

凌晨一点,产品经理在群里丢了一张截图:用户问“我刚才说的订单号你再重复一遍”,我们的智能客服机器人回了句“请您提供一下订单号”。用户连骂三句,截图发到了投诉平台。

这不是模型能力不行,是它把上下文“忘了”。更要命的是,同样的对话我手工测了三遍,一次都没复现。直到我用 Playwright 写了 200 条自动化记忆测试,才发现 90% 的遗忘都指向一个再基础不过的问题——前端存储的序列化与反序列化不一致。

问题拆解:记忆是怎么悄悄丢失的?

我们的智能客服基于 LLM + LangChain,前端用 React 写了一个聊天组件,对话历史存在浏览器的 localStorage 里。每次用户刷新页面或重新打开,前端会从 localStorage 把历史消息读出来拼到 prompt 里,借此维持长上下文记忆。

理想很丰满,现实是:很多用户在移动端浏览器上使用,页面经常被系统杀掉。当 session 恢复时,我们就靠这段本地缓存的对话历史来重建记忆。一旦这里的存储、读取有一丁点儿偏差,模型就会“失忆”。

手工测试为什么抓不住?因为我们会下意识地按“正确姿势”操作——先发消息 A,再发消息 B,最后问消息 A 的内容。但对真实用户来说,他们可能刷新页面、切后台、甚至手机没电重启,这些操作对前端存储的影响,手工很难反复模拟。常规的单元测试只测了 JS 逻辑,没法覆盖“浏览器级别的持久化与恢复”这个完整链路。

方案设计:为什么选 Playwright 做端到端记忆验证?

我需要一个能控制真实浏览器、能操作 localStorage、能拦截网络请求、还能断言页面内容的工具。Selenium 太笨重,Puppeteer 只支持 JS,Cypress 侵入性强且对多 tab 支持弱。Playwright 支持 Python,API 干净,能直接 page.evaluate()localStorage 里塞任意内容,也能模拟页面刷新、跨页面恢复,完美。

架构上我分了三层:

  • 用例层:定义记忆测试场景,比如“订单号在多轮对话后仍可被召回”“刷新后记忆不丢失”。
  • 驱动层:封装 Playwright 操作,提供 send_messagereload_and_restore 等高级动作。
  • 验证层:从页面文本和 localStorage 两个维度断言记忆一致性。

这样即使前端框架从 React 换成 Vue,我只需要改选择器,测试逻辑完全不动。

核心实现:让 Playwright 替你重复所有诡异操作

1. 搭建可重复的测试环境

我们需要一个能跑的聊天页面,这里用一个小 Streamlit 应用模拟(直接用 subprocess 启动,测试结束就杀掉,保证隔离)。实际上你可以换成任何前端页面。

import subprocess
import time
import pytest
from playwright.sync_api import sync_playwright

# 启动一个本地 Streamlit 聊天应用,模拟 LLM 对话服务
def start_chat_server():
    proc = subprocess.Popen(
        ["streamlit", "run", "chat_ui.py", "--server.port", "8510"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL
    )
    time.sleep(3)  # 等待服务就绪
    return proc

@pytest.fixture(scope="module")
def page():
    server = start_chat_server()
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context()
        page = context.new_page()
        page.goto("http://localhost:8510")
        yield page
        browser.close()
        server.terminate()

这段代码解决“如何保证每次测试从一个干净状态开始”。new_context() 保证隔离的存储空间,避免测试间污染。

2. 模拟多轮对话并断言记忆保持

下面这个测试是核心:用户先告知订单号,经过若干轮对话后,询问订单号,期望模型能正确复述。

def test_order_id_recall_after_conversation(page):
    # 封装发送消息的函数,处理前端可能的异步渲染
    def send_message(text: str):
        # 找到输入框并键入消息。实际选择器按你的前端结构调整
        page.fill('textarea[aria-label="输入消息"]', text)
        page.click('button:has-text("发送")')
        # 等待 AI 回复出现(以最后一条气泡里的文本为准)
        page.wait_for_selector(".message.assistant:last-of-type .content", timeout=5000)

    # 用户提供订单号
    send_message("我的订单号是 ORD-9876,查一下物流")
    # 中间几轮无关对话,目的是扩大上下文距离,更容易触达遗忘边界
    send_message("这个商品支持七天无理由吗?")
    send_message("发货一般要多久?")
    # 关键轮次:再次询问订单号
    send_message("我刚才说的订单号是什么?你重复一遍")

    # 获取最后一条助手的回复文本
    last_reply = page.text_content(".message.assistant:last-of-type .content")
    # 核心断言:回复中必须包含之前给出的订单号
    assert "ORD-9876" in last_reply, (
        f"记忆丢失!模型没有复述订单号,实际回复: {last_reply}"
    )

这步如果失败,就是典型的长上下文遗忘——手工测可能永远遇不到,因为手工不会每次都恰好凑足干扰轮次。

3. 测试刷新后记忆恢复——这是重灾区

用户刷新页面或从后台恢复时,前端会从 localStorage 重新构建对话历史。下面这个测试直接操作 localStorage,并强制刷新,再检验记忆。

def test_memory_survives_page_reload(page):
    # 先完成一轮交互,确保记忆已存入 localStorage
    page.fill('textarea[aria-label="输入消息"]', "我叫张三,请记住我的名字")
    page.click('button:has-text("发送")')
    page.wait_for_selector(".message.assistant:last-of-type .content")

    # 通过 evaluate 直接检查 localStorage 里的聊天记录是否完整
    stored = page.evaluate("() => JSON.parse(localStorage.getItem('chat_history') || '[]')")
    assert len(stored) >= 2, "localStorage 中没有存入足够的历史消息"

    # 强制刷新页面,模拟浏览器崩溃后恢复
    page.reload()
    page.wait_for_selector(".message:first-of-type", timeout=5000)

    # 刷新后继续问同一个名字,模型应该还记得
    page.fill('textarea[aria-label="输入消息"]', "我叫什么名字?")
    page.click('button:has-text("发送")')
    last_reply = page.text_content(".message.assistant:last-of-type .content")

    assert "张三" in last_reply, (
        f"刷新后记忆丢失!模型没有记住名字,实际回复: {last_reply}"
    )

这个测试帮我抓出了第一个坑。

踩坑记录:官方文档不会告诉你的两个反直觉问题

坑一:JSON.stringify 吞掉了 undefined,导致对话链断裂

localStorage 里的历史消息对象中某个字段为 undefined 时,JSON.stringify 会直接丢弃这个属性。下次 JSON.parse 后,历史消息的结构就变了,前端在 prompt 拼接时因为缺字段抛出异常,整个记忆被静默丢弃。

现象:刷新页面后,聊天窗口一片空白,没有任何历史记录,模型从头开始。

定位:我在 Playwright 脚本里加了 page.evaluate 直接抓取 localStorage 里的原始 JSON,和内存中的对象做 diff。发现转换后少了 metadata 字段(值为 undefined)。而这个字段是 prompt 模板的必填项。

解决:在存入前统一用 replacer 函数把 undefined 转为 null,并在读取时用 reviver 还原。同时在前端加了一层 schema 校验,不完整的记录直接丢弃并告警,而不是让整个页面崩掉。

// 存入时
localStorage.setItem('chat_history', JSON.stringify(history, (k, v) => v === undefined ? null : v));

坑二:记忆消息顺序在并发写入时错乱

我们的前端用了 React 的异步状态更新,有些用户操作会短时间内触发两次 setItem。由于 localStorage 不是事务性的,后一次写入可能覆盖前一次,导致消息 ID 顺序对不上。模型在拼接 prompt 时会按照错乱的时间戳排列,把最新的问题插到了对话中间,看起来就像“失忆”了——其实不是忘了,是把历史搞乱了。

现象:多轮对话后,首次回答正确,但连续追问时模型突然答非所问。手工测试因为节奏慢,几乎不会触发竞态。

定位:Playwright 中可以设置非常短的输入间隔,用 page.fill 连续填入消息并快速点击发送,同时监控 localStorage 的变更次数。在压测模式下,我捕获到一次写入时本地存储被覆盖的完整堆栈。

解决:引入简单的写入锁(基于 navigator.locks 或内存中的 Promise 队列),保证同一个对话的 setItem 按顺序串行。

效果验证:从“随机失忆”到 100% 可复现

我把这套 Playwright 用例揉进了 CI,每天自动跑 80 条记忆场景,覆盖刷新、切 tab、断网恢复、历史超长等边界。

指标优化前优化后
记忆测试通过率63%100%
刷新后记忆保持率41%99.2%
24h 回归跑出的新 bug-3 个(含序列化、竞态、跨域存储)

最大的收获不是数字,而是所有“随机出现”的遗忘问题全部变成了可稳定复现的失败用例,再也没有“我这复现不了”的扯皮。

能立刻用的代码模板

如果你也想给对话系统上记忆测试,直接拿这段 skeleton 改选择器就行:

from playwright.sync_api import sync_playwright

def run_memory_test(url, user_msg, query_msg, expected):
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.goto(url)
        page.fill('textarea', user_msg)
        page.click('button:has-text("发送")')
        page.wait_for_selector(".assistant")
        page.fill('textarea', query_msg)
        page.click('button:has-text("发送")')
        reply = page.text_content(".assistant:last-of-type")
        assert expected in reply, f"失败: {reply}"
        browser.close()

把它配在 GitHub Actions 里,每个 PR 自动跑一次,再也不用半夜起来修“偶发失忆”了。


#Playwright #大模型 #自动化测试 #上下文记忆 #前端踩坑

关于作者
一个天天跟 LLM 工程化死磕的后端/架构选手,坚信“能稳定复现的 bug 就等于修了一半”。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你省下一晚排查时间,请我喝杯咖啡
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege