Playwright测试AI记忆存储踩坑实录:这个时序问题让我排查了6小时

凌晨2点,我被PagerDuty的尖啸声炸醒。告警信息只有一行字:「用户反馈AI助手突然失忆,对话上下文全丢」。我迷迷糊糊打开监控,发现生产环境三分之一的内存存储写入操作返回了超时,但十分钟前的测试环境明明全绿。妈的,又是端到端测试没覆盖到的异步时序问题。

问题拆解:为什么AI记忆测试这么容易漏

我们的AI应用是一个基于LLM的智能客服,它会把对话历史和用户偏好写入Redis作为记忆存储。比如用户说"我叫张三,喜欢深色模式",后续对话中AI就必须记住这事。这类功能最怕的就是"表面写成功,实际没落库"------API返回200,但Redis还在刷盘,或者后台异步任务还没执行完。

常规测试怎么搞的?单元测试mock掉Redis,测不出真实延迟;集成测试用time.sleep(2)硬等,慢且不可靠;手工点一点页面倒是直观,但不可能覆盖各种时序组合。更隐蔽的问题是:Playwright的waitForLoadState('networkidle')只关心浏览器网络栈空闲,对后端异步任务完全无感知。这就留下了一个巨大的测试盲区------只要后台写入慢个100ms,断言就会在数据真正生效前执行,然后给你一个假红灯。

方案设计:让Playwright学会"等待异步落库"

这个问题其实核心就是一句话:如何把浏览器端操作与后端异步存储完成的时机精确对齐

我考虑过三条路:

  1. 让服务端暴露健康检查接口,测试轮询直到记忆键存在------可行但侵入性强,生产环境不能开这种后门。
  2. 用Playwright直接读Redis------跨层了,而且测试机器不一定有权限。
  3. 拦截前端发出的记忆相关API,监听响应完成,再辅以应用层的"记忆生效"证据------比如等AI下一轮回复中真的出现了之前存储的信息。

我选了第3条。理由很简单:端到端测试就应该从用户视角验证,AI能"记得"才算数。具体架构:pytest + Playwright 直接操控浏览器,对话接口是FastAPI应用,记忆存储用Redis。测试里用page.route()拦截网络请求,精确知道记忆保存API何时返回,之后再通过断言AI下一条回复内容来证明记忆已生效。不用sleep,不用轮询,全凭事件驱动。

核心实现

1. 启动浏览器并拦截记忆保存请求

这段代码建立一个带网络拦截的测试环境,能够捕获所有打到/api/memory的请求,并返回一个Future,让后续断言可以await记忆成功落盘(至少API返回成功)。

python 复制代码
import pytest
import asyncio
from playwright.async_api import async_playwright, Page, Route

@pytest.fixture
async def page_with_memory_tracker():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context()
        page = await context.new_page()
        
        # 存储每个记忆保存操作对应的Future,用于精确等待
        memory_write_events = []
        
        async def track_memory_request(route: Route):
            req = route.request
            if '/api/memory' in req.url and req.method == 'POST':
                # 为这个写入创建一个Future,响应完成后set
                future = asyncio.get_event_loop().create_future()
                memory_write_events.append(future)
                response = await route.fetch()
                await route.fulfill(response=response)
                future.set_result(True)   # 只有API响应完成才通知
            else:
                await route.continue_()
        
        await page.route('**/*', track_memory_request)
        
        yield page, memory_write_events
        
        await browser.close()

2. 模拟多轮对话并验证记忆生效

这个测试用例完整走通"设置记忆→记忆写入确认→新对话引用记忆"的链路,不依赖任何硬等待。

python 复制代码
@pytest.mark.asyncio
async def test_ai_remembers_user_name(page_with_memory_tracker):
    page, memory_events = page_with_memory_tracker
    await page.goto('https://chat.example.com')
    
    # 第一轮:告诉AI我的名字
    await page.fill('#chat-input', '我叫张三,喜欢极简模式')
    await page.click('#send-btn')
    
    # 等待记忆保存API完成(避免下轮对话时记忆尚未生效)
    if memory_events:
        await memory_events[-1]  # 阻塞直到最近的记忆写入future完成
    
    # 第二轮:问一个需要记忆才能回答的问题
    await page.fill('#chat-input', '我刚才说我叫什么?')
    await page.click('#send-btn')
    
    # 等待AI回复中出现"张三",超时10秒,足够从Redis加载记忆
    await page.wait_for_function(
        """() => {
            const msgs = document.querySelectorAll('.assistant-message');
            const last = msgs[msgs.length - 1];
            return last && last.textContent.includes('张三');
        }""",
        timeout=10000
    )
    
    # 如果跑到这里还没抛异常,说明记忆端到端有效
    assert True

这里的关键设计:不是等网络空闲,而是等"记忆写入完成"的精确事件;也不是等DOM出现任意回复,而是等回复内容包含"张三"。这样就把时序对齐的粒度从"页面安静了"提升到了"业务事实发生了"。

踩坑记录

坑1:waitForResponse 返回了,Redis里却还是空的

现象:明明监听到 POST /api/memory 返回201,紧接着发第二条消息,AI还是没记住。我在Grafana上看到Redis的写入QPS有毛刺------原来服务端收到请求后,把实际写Redis的操作丢进了asyncio.create_task,API返回时协程才刚被调度。waitForResponse只保证了HTTP完成,不保证副作用完成。

解决方案:不依赖后端改造。在测试中,等待记忆API返回后,额外用page.wait_for_function轮询页面上的"记忆已保存"提示(产品本身有这个toast),或者改为监听与记忆加载相关的API(如GET /api/memory)的成功返回。最终我选择等待AI的下一条回复中包含期望内容------因为那是记忆生效的唯一确凿证据。

坑2:CI中测试间歇性失败,本地却全绿

现象:同一个用例在GitHub Actions上每跑5次就挂1次,报错都是wait_for_function超时。本地无论怎么跑都没事。我怀疑过Redis慢查询、网络抖动,最后发现是CI的Chrome启动参数遗漏了--disable-dev-shm-usage,导致页面在内存受限时渲染卡顿,发送按钮点击事件实际晚于DOM更新。Playwright的click虽然会等元素可交互,但页面半卡死时它的内部超时计算会出现偏差。

解决:在CI的LaunchOptions中统一设置:

python 复制代码
browser = await p.chromium.launch(headless=True, args=['--disable-dev-shm-usage'])

另外把wait_for_function超时从默认10秒调到20秒,给CI的土豆机器留点喘息空间。这就叫:别在时间断言上跟CI斤斤计较。

效果验证

指标 优化前(networkidle+sleep) 优化后(事件驱动+业务断言)
测试通过率 62%(8/13次失败) 100%(50次全绿)
单次执行时间 18s 9s
误报根因排查耗时 6小时+ 0(失败就是真bug)

最让我舒服的是,那个半夜三点排查6小时最后只改了三行代码的悲剧,再也不会重演了。

可直接用的代码/工具

把下面这个独立函数复制进你的conftest.py,就可以在任何Playwright用例里用await wait_for_memory_effect(page, text)等待记忆生效:

python 复制代码
async def wait_for_memory_effect(page, keyword, timeout=15000):
    await page.wait_for_function(
        f"() => document.body.innerText.includes('{keyword}')",
        timeout=timeout
    )

#Playwright #端到端测试 #AI应用 #Redis #踩坑

关于作者

我是阿福,一个在创业公司摸爬滚打多年的后端/架构实战派,专治各种测试环境和生产环境不一致的疑难杂症。

GitHub: github.com/baofugege

Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你省下了排查时间,请我喝杯咖啡。

提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege

相关推荐
IT_陈寒1 小时前
Redis踩了个大坑,原来DEL命令也会卡住整个实例
前端·人工智能·后端
一个水瓶座程序猿.1 小时前
基于Spring AI RAG 的AI知识库前端交互实现
前端·人工智能·spring
breeze jiang2 小时前
React + TypeScript 编辑表单:为什么要区分 name 和 editingName
前端·typescript
米码收割机2 小时前
【移动】线上购物移动端网站(源码+文档)【独一无二】
java·开发语言·前端·python·django
To_OC2 小时前
对接大模型流式接口,我被一个 ReadableStream 卡了半小时
前端·node.js·llm
石小石Orz12 小时前
我发现了开发者AI产品营收的新方向
前端·虚拟现实
老马识途2.012 小时前
关于跨域问题的总结
java·前端
别惊醒渔人14 小时前
Vue3 Diff 优化:最长递增子序列 LIS
前端·javascript·vue.js
颜酱14 小时前
07 | 把字段与指标同步到 Qdrant(生成阶段)
前端·人工智能·后端